diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index fa6110a073..0ce11c3ac2 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -14,6 +14,8 @@ 🐞 Fixed - Fixed mistakenly passing the hyperlink text to the `onLinkTap` callback instead of the actual `href`. +- Fixed high memory usage when displaying multiple image + attachments. [[#2228]](https://github.com/GetStream/stream-chat-flutter/issues/2228) ## 9.19.0 diff --git a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart index 8824bdec7a..5a3837b487 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart @@ -12,7 +12,7 @@ class StreamImageAttachment extends StatelessWidget { required this.image, this.shape, this.constraints = const BoxConstraints(), - this.imageThumbnailSize = const Size(400, 400), + this.imageThumbnailSize, this.imageThumbnailResizeType = 'clip', this.imageThumbnailCropType = 'center', }); @@ -32,7 +32,7 @@ class StreamImageAttachment extends StatelessWidget { final BoxConstraints constraints; /// Size of the attachment image thumbnail. - final Size imageThumbnailSize; + final Size? imageThumbnailSize; /// Resize type of the image attachment thumbnail. /// diff --git a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/giphy_attachment_thumbnail.dart b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/giphy_attachment_thumbnail.dart index c38e2800e8..952b0f04e4 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/giphy_attachment_thumbnail.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/giphy_attachment_thumbnail.dart @@ -1,9 +1,6 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:shimmer/shimmer.dart'; import 'package:stream_chat_flutter/src/attachment/thumbnail/image_attachment_thumbnail.dart'; import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_error.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; /// {@template giphyAttachmentThumbnail} @@ -58,46 +55,16 @@ class StreamGiphyAttachmentThumbnail extends StatelessWidget { @override Widget build(BuildContext context) { - // If the giphy info is not available, use the image attachment thumbnail - // instead. + // Get the giphy info based on the selected type. final info = giphy.giphyInfo(type); - if (info == null) { - return StreamImageAttachmentThumbnail( - image: giphy, - width: width, - height: height, - fit: fit, - ); - } - - return CachedNetworkImage( - imageUrl: info.url, + // Build the image attachment thumbnail using the giphy info url if + // available or fallback to the original giphy url. + return StreamImageAttachmentThumbnail( + image: giphy.copyWith(imageUrl: info?.url), width: width, height: height, fit: fit, - placeholder: (context, __) { - final image = Image.asset( - 'lib/assets/images/placeholder.png', - width: width, - height: height, - fit: BoxFit.cover, - package: 'stream_chat_flutter', - ); - - final colorTheme = StreamChatTheme.of(context).colorTheme; - return Shimmer.fromColors( - baseColor: colorTheme.disabled, - highlightColor: colorTheme.inputBg, - child: image, - ); - }, - errorWidget: (context, url, error) { - return errorBuilder( - context, - error, - StackTrace.current, - ); - }, + errorBuilder: errorBuilder, ); } } diff --git a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/image_attachment_thumbnail.dart b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/image_attachment_thumbnail.dart index 39f34ce8ac..069fbe203d 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/image_attachment_thumbnail.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/image_attachment_thumbnail.dart @@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:shimmer/shimmer.dart'; import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_error.dart'; +import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_size_calculator.dart'; import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/utils/utils.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; @@ -72,43 +73,64 @@ class StreamImageAttachmentThumbnail extends StatelessWidget { @override Widget build(BuildContext context) { - final file = image.file; - if (file != null) { - return _LocalImageAttachment( - file: file, - width: width, - height: height, - fit: fit, - errorBuilder: errorBuilder, - ); - } + return LayoutBuilder( + builder: (context, constraints) { + // Calculate optimal thumbnail size once for all paths + final effectiveThumbnailSize = switch (thumbnailSize) { + final thumbnailSize? => thumbnailSize, + _ => ThumbnailSizeCalculator.calculate( + targetSize: constraints.biggest, + originalSize: image.originalSize, + pixelRatio: MediaQuery.devicePixelRatioOf(context), + ), + }; + + final cacheWidth = effectiveThumbnailSize?.width.round(); + final cacheHeight = effectiveThumbnailSize?.height.round(); + + // If the remote image URL is available, we can directly show it using + // the _RemoteImageAttachment widget. + if (image.thumbUrl ?? image.imageUrl case final imageUrl?) { + var resizedImageUrl = imageUrl; + if (effectiveThumbnailSize case final thumbnailSize?) { + resizedImageUrl = imageUrl.getResizedImageUrl( + crop: thumbnailCropType, + resize: thumbnailResizeType, + width: thumbnailSize.width, + height: thumbnailSize.height, + ); + } + + return _RemoteImageAttachment( + url: resizedImageUrl, + width: width, + height: height, + fit: fit, + cacheWidth: cacheWidth, + cacheHeight: cacheHeight, + errorBuilder: errorBuilder, + ); + } + + // Otherwise, we try to show the local image file. + if (image.file case final file?) { + return _LocalImageAttachment( + file: file, + width: width, + height: height, + fit: fit, + cacheWidth: cacheWidth, + cacheHeight: cacheHeight, + errorBuilder: errorBuilder, + ); + } - var imageUrl = image.thumbUrl ?? image.imageUrl ?? image.assetUrl; - if (imageUrl != null) { - final thumbnailSize = this.thumbnailSize; - if (thumbnailSize != null) { - imageUrl = imageUrl.getResizedImageUrl( - width: thumbnailSize.width, - height: thumbnailSize.height, - resize: thumbnailResizeType, - crop: thumbnailCropType, + return errorBuilder( + context, + 'Image attachment is not valid', + StackTrace.current, ); - } - - return _RemoteImageAttachment( - url: imageUrl, - width: width, - height: height, - fit: fit, - errorBuilder: errorBuilder, - ); - } - - // Return error widget if no image is found. - return errorBuilder( - context, - 'Image attachment is not valid', - StackTrace.current, + }, ); } } @@ -119,12 +141,16 @@ class _LocalImageAttachment extends StatelessWidget { required this.errorBuilder, this.width, this.height, + this.cacheWidth, + this.cacheHeight, this.fit, }); final AttachmentFile file; final double? width; final double? height; + final int? cacheWidth; + final int? cacheHeight; final BoxFit? fit; final ThumbnailErrorBuilder errorBuilder; @@ -136,6 +162,8 @@ class _LocalImageAttachment extends StatelessWidget { bytes, width: width, height: height, + cacheWidth: cacheWidth, + cacheHeight: cacheHeight, fit: fit, errorBuilder: errorBuilder, ); @@ -147,6 +175,8 @@ class _LocalImageAttachment extends StatelessWidget { File(path), width: width, height: height, + cacheWidth: cacheWidth, + cacheHeight: cacheHeight, fit: fit, errorBuilder: errorBuilder, ); @@ -167,12 +197,16 @@ class _RemoteImageAttachment extends StatelessWidget { required this.errorBuilder, this.width, this.height, + this.cacheWidth, + this.cacheHeight, this.fit, }); final String url; final double? width; final double? height; + final int? cacheWidth; + final int? cacheHeight; final BoxFit? fit; final ThumbnailErrorBuilder errorBuilder; @@ -182,6 +216,8 @@ class _RemoteImageAttachment extends StatelessWidget { imageUrl: url, width: width, height: height, + memCacheWidth: cacheWidth, + memCacheHeight: cacheHeight, fit: fit, placeholder: (context, __) { final image = Image.asset( diff --git a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/thumbnail_size_calculator.dart b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/thumbnail_size_calculator.dart new file mode 100644 index 0000000000..94756f2e67 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/thumbnail_size_calculator.dart @@ -0,0 +1,79 @@ +import 'dart:ui'; + +/// Utility class for calculating optimal thumbnail sizes for image +/// attachments. +/// +/// This calculator ensures that images are decoded and cached at +/// appropriate sizes based on display constraints, maintaining aspect +/// ratio while accounting for device pixel density. +class ThumbnailSizeCalculator { + ThumbnailSizeCalculator._(); + + /// Calculates the optimal thumbnail size for an image attachment. + /// + /// Returns `null` if: + /// - Both [targetSize] dimensions are infinite + /// - [originalSize] is not available (needed for aspect ratio) + /// + /// The calculation: + /// 1. Handles infinite constraints by calculating from the finite + /// dimension + /// 2. Maintains aspect ratio to prevent image distortion + /// 3. Applies [pixelRatio] for device-appropriate resolution + /// + /// Example: + /// ```dart + /// final size = ThumbnailSizeCalculator.calculate( + /// originalSize: Size(1920, 1080), + /// targetSize: Size(400, 300), + /// pixelRatio: 2.0, + /// ); + /// // Returns: Size(800, 450) - maintains 16:9 aspect ratio, + /// // scaled for 2x display + /// ``` + static Size? calculate({ + Size? originalSize, + required Size targetSize, + required double pixelRatio, + }) { + final originalAspectRatio = originalSize?.aspectRatio; + // If original aspect ratio is not available, skip optimization + // We need the aspect ratio to avoid incorrect cropping + if (originalAspectRatio == null) return null; + + // Invalid aspect ratio indicates invalid original size + if (originalAspectRatio.isInfinite || originalAspectRatio <= 0) { + return null; + } + + var thumbnailWidth = targetSize.width; + var thumbnailHeight = targetSize.height; + + // Cannot calculate optimal size with infinite constraints + if (thumbnailWidth.isInfinite && thumbnailHeight.isInfinite) { + return null; + } + + if (thumbnailWidth.isInfinite) { + // Width is infinite, calculate from height + thumbnailWidth = thumbnailHeight * originalAspectRatio; + } + if (thumbnailHeight.isInfinite) { + // Height is infinite, calculate from width + thumbnailHeight = thumbnailWidth / originalAspectRatio; + } + + // Calculate size that maintains aspect ratio within constraints + final targetAspectRatio = thumbnailWidth / thumbnailHeight; + if (originalAspectRatio > targetAspectRatio) { + // Image is wider than container - fit to width + thumbnailHeight = thumbnailWidth / originalAspectRatio; + } else { + // Image is taller than container - fit to height + thumbnailWidth = thumbnailHeight * originalAspectRatio; + } + + // Apply pixel ratio to get physical pixel dimensions + return Size(thumbnailWidth * pixelRatio, thumbnailHeight * pixelRatio); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/video_attachment_thumbnail.dart b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/video_attachment_thumbnail.dart index 6e3c71aa2f..946023f6ec 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/video_attachment_thumbnail.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/video_attachment_thumbnail.dart @@ -1,6 +1,6 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:shimmer/shimmer.dart'; +import 'package:stream_chat_flutter/src/attachment/thumbnail/image_attachment_thumbnail.dart'; import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_error.dart'; import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/video/video_thumbnail_image.dart'; @@ -54,36 +54,16 @@ class StreamVideoAttachmentThumbnail extends StatelessWidget { @override Widget build(BuildContext context) { - final thumbUrl = video.thumbUrl; - if (thumbUrl != null) { - return CachedNetworkImage( - imageUrl: thumbUrl, + final containsThumbnail = video.thumbUrl != null; + // If thumbnail is available, we can directly show it using the + // StreamImageAttachmentThumbnail widget. + if (containsThumbnail) { + return StreamImageAttachmentThumbnail( + image: video, width: width, height: height, fit: fit, - placeholder: (context, __) { - final image = Image.asset( - 'lib/assets/images/placeholder.png', - width: width, - height: height, - fit: BoxFit.cover, - package: 'stream_chat_flutter', - ); - - final colorTheme = StreamChatTheme.of(context).colorTheme; - return Shimmer.fromColors( - baseColor: colorTheme.disabled, - highlightColor: colorTheme.inputBg, - child: image, - ); - }, - errorWidget: (context, url, error) { - return errorBuilder( - context, - error, - StackTrace.current, - ); - }, + errorBuilder: errorBuilder, ); } diff --git a/packages/stream_chat_flutter/lib/src/avatars/user_avatar.dart b/packages/stream_chat_flutter/lib/src/avatars/user_avatar.dart index aae50f481e..f62e40bcff 100644 --- a/packages/stream_chat_flutter/lib/src/avatars/user_avatar.dart +++ b/packages/stream_chat_flutter/lib/src/avatars/user_avatar.dart @@ -77,60 +77,79 @@ class StreamUserAvatar extends StatelessWidget { @override Widget build(BuildContext context) { - final hasImage = user.image != null && user.image!.isNotEmpty; final streamChatTheme = StreamChatTheme.of(context); + final colorTheme = streamChatTheme.colorTheme; + final avatarTheme = streamChatTheme.ownMessageTheme.avatarTheme; final streamChatConfig = StreamChatConfiguration.of(context); - final placeholder = - this.placeholder ?? streamChatConfig.placeholderUserImage; + final effectivePlaceholder = switch (placeholder) { + final placeholder? => placeholder, + _ => streamChatConfig.placeholderUserImage, + }; + + final effectiveBorderRadius = borderRadius ?? avatarTheme?.borderRadius; final backupGradientAvatar = ClipRRect( - borderRadius: borderRadius ?? - streamChatTheme.ownMessageTheme.avatarTheme?.borderRadius ?? - BorderRadius.zero, + borderRadius: effectiveBorderRadius ?? BorderRadius.zero, child: streamChatConfig.defaultUserImage(context, user), ); Widget avatar = FittedBox( fit: BoxFit.cover, child: Container( - constraints: constraints ?? - streamChatTheme.ownMessageTheme.avatarTheme?.constraints, - child: hasImage - ? CachedNetworkImage( - fit: BoxFit.cover, - filterQuality: FilterQuality.high, - imageUrl: user.image!, - errorWidget: (context, __, ___) => backupGradientAvatar, - placeholder: placeholder != null - ? (context, __) => placeholder(context, user) - : null, - imageBuilder: (context, imageProvider) => DecoratedBox( - decoration: BoxDecoration( - borderRadius: borderRadius ?? - streamChatTheme - .ownMessageTheme.avatarTheme?.borderRadius, - image: DecorationImage( - image: imageProvider, - fit: BoxFit.cover, + constraints: constraints ?? avatarTheme?.constraints, + child: LayoutBuilder( + builder: (context, constraints) { + final imageUrl = user.image; + if (imageUrl == null || imageUrl.isEmpty) { + return backupGradientAvatar; + } + + // Calculate optimal thumbnail size for the avatar + final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); + final thumbnailSize = constraints.biggest * devicePixelRatio; + + int? cacheWidth, cacheHeight; + if (thumbnailSize.isFinite && !thumbnailSize.isEmpty) { + cacheWidth = thumbnailSize.width.round(); + cacheHeight = thumbnailSize.height.round(); + } + + return CachedNetworkImage( + fit: BoxFit.cover, + filterQuality: FilterQuality.high, + imageUrl: imageUrl, + errorWidget: (_, __, ___) => backupGradientAvatar, + placeholder: switch (effectivePlaceholder) { + final holder? => (context, __) => holder(context, user), + _ => null, + }, + imageBuilder: (context, imageProvider) => DecoratedBox( + decoration: BoxDecoration( + borderRadius: effectiveBorderRadius, + image: DecorationImage( + fit: BoxFit.cover, + image: ResizeImage( + imageProvider, + width: cacheWidth, + height: cacheHeight, ), ), ), - ) - : backupGradientAvatar, + ), + ); + }, + ), ), ); if (selected) { avatar = ClipRRect( - borderRadius: (borderRadius ?? - streamChatTheme.ownMessageTheme.avatarTheme?.borderRadius ?? - BorderRadius.zero) + + borderRadius: (effectiveBorderRadius ?? BorderRadius.zero) + BorderRadius.circular(selectionThickness), child: Container( - constraints: constraints ?? - streamChatTheme.ownMessageTheme.avatarTheme?.constraints, - color: selectionColor ?? streamChatTheme.colorTheme.accentPrimary, + constraints: constraints ?? avatarTheme?.constraints, + color: selectionColor ?? colorTheme.accentPrimary, child: Padding( padding: EdgeInsets.all(selectionThickness), child: avatar, @@ -150,7 +169,7 @@ class StreamUserAvatar extends StatelessWidget { alignment: onlineIndicatorAlignment, child: Material( type: MaterialType.circle, - color: streamChatTheme.colorTheme.barsBg, + color: colorTheme.barsBg, child: Container( margin: const EdgeInsets.all(2), constraints: onlineIndicatorConstraints ?? @@ -160,7 +179,7 @@ class StreamUserAvatar extends StatelessWidget { ), child: Material( shape: const CircleBorder(), - color: streamChatTheme.colorTheme.accentInfo, + color: colorTheme.accentInfo, ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/channel/stream_channel_avatar.dart b/packages/stream_chat_flutter/lib/src/channel/stream_channel_avatar.dart index 6c71eee4ee..68365d8a59 100644 --- a/packages/stream_chat_flutter/lib/src/channel/stream_channel_avatar.dart +++ b/packages/stream_chat_flutter/lib/src/channel/stream_channel_avatar.dart @@ -128,13 +128,29 @@ class StreamChannelAvatar extends StatelessWidget { decoration: BoxDecoration(color: colorTheme.accentPrimary), child: InkWell( onTap: onTap, - child: channelImage.isEmpty - ? fallbackWidget - : CachedNetworkImage( - imageUrl: channelImage, - errorWidget: (_, __, ___) => fallbackWidget, - fit: BoxFit.cover, - ), + child: LayoutBuilder( + builder: (context, constraints) { + if (channelImage.isEmpty) return fallbackWidget; + + // Calculate optimal thumbnail size for the avatar + final devicePixel = MediaQuery.devicePixelRatioOf(context); + final thumbnailSize = constraints.biggest * devicePixel; + + int? cacheWidth, cacheHeight; + if (thumbnailSize.isFinite && !thumbnailSize.isEmpty) { + cacheWidth = thumbnailSize.width.round(); + cacheHeight = thumbnailSize.height.round(); + } + + return CachedNetworkImage( + imageUrl: channelImage, + memCacheWidth: cacheWidth, + memCacheHeight: cacheHeight, + errorWidget: (_, __, ___) => fallbackWidget, + fit: BoxFit.cover, + ); + }, + ), ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/gallery/gallery_footer.dart b/packages/stream_chat_flutter/lib/src/gallery/gallery_footer.dart index 76db88224e..ffcfef6cd2 100644 --- a/packages/stream_chat_flutter/lib/src/gallery/gallery_footer.dart +++ b/packages/stream_chat_flutter/lib/src/gallery/gallery_footer.dart @@ -1,6 +1,5 @@ import 'dart:io'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; @@ -244,10 +243,8 @@ class _StreamGalleryFooterState extends State { onTap: () => widget.mediaSelectedCallBack!(index), child: AspectRatio( aspectRatio: 1, - child: CachedNetworkImage( - imageUrl: attachment.imageUrl ?? - attachment.assetUrl ?? - attachment.thumbUrl!, + child: StreamImageAttachmentThumbnail( + image: attachment, fit: BoxFit.cover, ), ), diff --git a/packages/stream_chat_flutter/test/src/attachment/thumbnail/thumbnail_size_calculator_test.dart b/packages/stream_chat_flutter/test/src/attachment/thumbnail/thumbnail_size_calculator_test.dart new file mode 100644 index 0000000000..21c47644fa --- /dev/null +++ b/packages/stream_chat_flutter/test/src/attachment/thumbnail/thumbnail_size_calculator_test.dart @@ -0,0 +1,272 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_size_calculator.dart'; + +void main() { + group('ThumbnailSizeCalculator.calculate', () { + group('returns null when', () { + test('both target dimensions are infinite', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1920, 1080), + targetSize: Size.infinite, + pixelRatio: 1, + ); + + expect(result, isNull); + }); + + test('original size is null', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: null, + targetSize: const Size(400, 300), + pixelRatio: 1, + ); + + expect(result, isNull); + }); + + test('original size has zero width', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(0, 1080), + targetSize: const Size(400, 300), + pixelRatio: 1, + ); + + expect(result, isNull); + }); + + test('original size has zero height', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1920, 0), + targetSize: const Size(400, 300), + pixelRatio: 1, + ); + + expect(result, isNull); + }); + }); + + group('with finite constraints', () { + test('maintains aspect ratio when fitting wider image in container', () { + // 16:9 image (1920x1080) fitting into 400x300 container + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1920, 1080), + targetSize: const Size(400, 300), + pixelRatio: 1, + ); + + expect(result, isNotNull); + // Should fit to width: 400x225 (maintains 16:9) + expect(result!.width, closeTo(400, 0.01)); + expect(result.height, closeTo(225, 0.01)); + }); + + test('maintains aspect ratio when fitting taller image in container', () { + // 9:16 image (1080x1920) fitting into 400x300 container + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1080, 1920), + targetSize: const Size(400, 300), + pixelRatio: 1, + ); + + expect(result, isNotNull); + // Should fit to height: 168.75x300 (maintains 9:16) + expect(result!.width, closeTo(168.75, 0.01)); + expect(result.height, closeTo(300, 0.01)); + }); + + test('applies pixel ratio correctly', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1920, 1080), + targetSize: const Size(400, 300), + pixelRatio: 2, + ); + + expect(result, isNotNull); + // 400x225 at 2x = 800x450 + expect(result!.width, closeTo(800, 0.01)); + expect(result.height, closeTo(450, 0.01)); + }); + + test('handles 3x pixel ratio', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1920, 1080), + targetSize: const Size(400, 300), + pixelRatio: 3, + ); + + expect(result, isNotNull); + // 400x225 at 3x = 1200x675 + expect(result!.width, closeTo(1200, 0.01)); + expect(result.height, closeTo(675, 0.01)); + }); + + test('handles square images', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1000, 1000), + targetSize: const Size(400, 300), + pixelRatio: 1, + ); + + expect(result, isNotNull); + // Should fit to height: 300x300 (maintains 1:1) + expect(result!.width, closeTo(300, 0.01)); + expect(result.height, closeTo(300, 0.01)); + }); + + test('handles very wide panorama images', () { + // 21:9 ultra-wide + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(2560, 1080), + targetSize: const Size(400, 300), + pixelRatio: 1, + ); + + expect(result, isNotNull); + // Should fit to width + expect(result!.width, closeTo(400, 0.01)); + expect(result.height, closeTo(168.75, 0.01)); + }); + + test('handles very tall images', () { + // Tall image like a mobile screenshot + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1080, 2340), + targetSize: const Size(400, 300), + pixelRatio: 1, + ); + + expect(result, isNotNull); + // Should fit to height + expect(result!.width, closeTo(138.46, 0.01)); + expect(result.height, closeTo(300, 0.01)); + }); + }); + + group('with infinite width', () { + test('calculates width from height maintaining aspect ratio', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1920, 1080), + targetSize: const Size(double.infinity, 300), + pixelRatio: 1, + ); + + expect(result, isNotNull); + // 16:9 aspect ratio: width = 300 * (16/9) = 533.33 + expect(result!.width, closeTo(533.33, 0.01)); + expect(result.height, closeTo(300, 0.01)); + }); + + test('applies pixel ratio after calculating width', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1920, 1080), + targetSize: const Size(double.infinity, 300), + pixelRatio: 2, + ); + + expect(result, isNotNull); + // 533.33x300 at 2x = 1066.66x600 + expect(result!.width, closeTo(1066.66, 0.01)); + expect(result.height, closeTo(600, 0.01)); + }); + }); + + group('with infinite height', () { + test('calculates height from width maintaining aspect ratio', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1920, 1080), + targetSize: const Size(400, double.infinity), + pixelRatio: 1, + ); + + expect(result, isNotNull); + // 16:9 aspect ratio: height = 400 / (16/9) = 225 + expect(result!.width, closeTo(400, 0.01)); + expect(result.height, closeTo(225, 0.01)); + }); + + test('applies pixel ratio after calculating height', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1920, 1080), + targetSize: const Size(400, double.infinity), + pixelRatio: 2, + ); + + expect(result, isNotNull); + // 400x225 at 2x = 800x450 + expect(result!.width, closeTo(800, 0.01)); + expect(result.height, closeTo(450, 0.01)); + }); + }); + + group('edge cases', () { + test('handles very small target sizes', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1920, 1080), + targetSize: const Size(10, 10), + pixelRatio: 1, + ); + + expect(result, isNotNull); + // Should maintain aspect ratio even for tiny sizes + expect(result!.width, closeTo(10, 0.01)); + expect(result.height, closeTo(5.625, 0.01)); + }); + + test('handles very large target sizes', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1920, 1080), + targetSize: const Size(4000, 3000), + pixelRatio: 1, + ); + + expect(result, isNotNull); + // Should still maintain aspect ratio for upscaling + expect(result!.width, closeTo(4000, 0.01)); + expect(result.height, closeTo(2250, 0.01)); + }); + + test('handles fractional pixel ratio', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1920, 1080), + targetSize: const Size(400, 300), + pixelRatio: 1.5, + ); + + expect(result, isNotNull); + // 400x225 at 1.5x = 600x337.5 + expect(result!.width, closeTo(600, 0.01)); + expect(result.height, closeTo(337.5, 0.01)); + }); + + test('handles pixel ratio of 1.0', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1920, 1080), + targetSize: const Size(400, 300), + pixelRatio: 1, + ); + + expect(result, isNotNull); + expect(result!.width, closeTo(400, 0.01)); + expect(result.height, closeTo(225, 0.01)); + }); + + test('handles original size smaller than target', () { + // Small original image (100x100) being scaled up to 400x300 + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(100, 100), + targetSize: const Size(400, 300), + pixelRatio: 1, + ); + + expect(result, isNotNull); + // Should still maintain aspect ratio (1:1) + expect(result!.width, closeTo(300, 0.01)); + expect(result.height, closeTo(300, 0.01)); + }); + }); + }); +}