diff --git a/packages/flyer_chat_image_message/lib/flyer_chat_image_message.dart b/packages/flyer_chat_image_message/lib/flyer_chat_image_message.dart index c10aa6d2c..cf13ded81 100644 --- a/packages/flyer_chat_image_message/lib/flyer_chat_image_message.dart +++ b/packages/flyer_chat_image_message/lib/flyer_chat_image_message.dart @@ -2,4 +2,5 @@ library; export 'src/flyer_chat_image_message.dart'; -export 'src/get_image_dimensions.dart'; +export 'src/flyer_chat_multi_images_message.dart'; +export 'src/helpers/get_image_dimensions.dart'; diff --git a/packages/flyer_chat_image_message/lib/src/flyer_chat_image_message.dart b/packages/flyer_chat_image_message/lib/src/flyer_chat_image_message.dart index f6d337bfd..883f553ba 100644 --- a/packages/flyer_chat_image_message/lib/src/flyer_chat_image_message.dart +++ b/packages/flyer_chat_image_message/lib/src/flyer_chat_image_message.dart @@ -9,7 +9,9 @@ import 'package:provider/provider.dart'; import 'package:thumbhash/thumbhash.dart' show rgbaToBmp, thumbHashToApproximateAspectRatio, thumbHashToRGBA; -import 'get_image_dimensions.dart'; +import 'helpers/get_image_dimensions.dart'; +import 'widgets/single_image_container.dart'; +import 'widgets/time_and_status.dart'; /// A widget that displays an image message. /// @@ -102,12 +104,10 @@ class FlyerChatImageMessage extends StatefulWidget { } /// State for [FlyerChatImageMessage]. -class _FlyerChatImageMessageState extends State - with TickerProviderStateMixin { +class _FlyerChatImageMessageState extends State { late final ChatController _chatController; late ImageProvider _imageProvider; late double _aspectRatio; - ImageProvider? _placeholderProvider; @override void initState() { @@ -124,23 +124,11 @@ class _FlyerChatImageMessageState extends State ); _aspectRatio = thumbHashToApproximateAspectRatio(thumbhashBytes); - - final rgbaImage = thumbHashToRGBA(thumbhashBytes); - final bmp = rgbaToBmp(rgbaImage); - _placeholderProvider = MemoryImage(bmp); - } else if (widget.message.blurhash?.isNotEmpty ?? false) { - _aspectRatio = 1; - - final blurhash = BlurHash.decode(widget.message.blurhash!); - final image = blurhash.toImage(35, 20); - final jpg = encodeJpg(image); - _placeholderProvider = MemoryImage(jpg); } else { _aspectRatio = 1; } _chatController = context.read(); - _imageProvider = _targetProvider; if (width == null || height == null) { getImageDimensions(_imageProvider).then((dimensions) { @@ -158,29 +146,6 @@ class _FlyerChatImageMessageState extends State } } - @override - void didUpdateWidget(covariant FlyerChatImageMessage oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.message.source != widget.message.source || - oldWidget.headers != widget.headers) { - final newImage = _targetProvider; - - precacheImage(newImage, context).then((_) { - if (mounted) { - _imageProvider = newImage; - } - }); - } - } - - @override - void dispose() { - _placeholderProvider?.evict(); - // Evicting the image on dispose will result in images flickering - // PaintingBinding.instance.imageCache.evict(_imageProvider); - super.dispose(); - } - @override Widget build(BuildContext context) { final theme = context.watch(); @@ -210,91 +175,21 @@ class _FlyerChatImageMessageState extends State child: Stack( fit: StackFit.expand, children: [ - _placeholderProvider != null - ? Image(image: _placeholderProvider!, fit: BoxFit.fill) - : Container( - color: - widget.placeholderColor ?? - theme.colors.surfaceContainerLow, - ), - Image( - image: _imageProvider, - fit: BoxFit.fill, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) { - return child; - } - - return Container( - color: - widget.loadingOverlayColor ?? - theme.colors.surfaceContainerLow.withValues(alpha: 0.5), - child: Center( - child: CircularProgressIndicator( - color: - widget.loadingIndicatorColor ?? - theme.colors.onSurface.withValues(alpha: 0.8), - strokeCap: StrokeCap.round, - value: - loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - ), - ), - ); - }, - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - var content = child; - - if (widget.overlay != null && - widget.message.hasOverlay == true && - frame != null) { - content = Stack( - fit: StackFit.expand, - children: [child, widget.overlay!], - ); - } - - if (wasSynchronouslyLoaded) { - return content; - } - - return AnimatedOpacity( - duration: const Duration(milliseconds: 250), - opacity: frame == null ? 0 : 1, - curve: Curves.linearToEaseOut, - child: content, - ); - }, + SingleImageContainer( + source: widget.message.source, + headers: widget.headers, + thumbhash: widget.message.thumbhash, + blurhash: widget.message.blurhash, + overlay: widget.overlay, + hasOverlay: widget.message.hasOverlay, + placeholderColor: widget.placeholderColor, + loadingOverlayColor: widget.loadingOverlayColor, + loadingIndicatorColor: widget.loadingIndicatorColor, + uploadOverlayColor: widget.uploadOverlayColor, + uploadIndicatorColor: widget.uploadIndicatorColor, + customImageProvider: widget.customImageProvider, + uploadProgressId: widget.message.id, ), - if (_chatController is UploadProgressMixin) - StreamBuilder( - stream: (_chatController as UploadProgressMixin) - .getUploadProgress(widget.message.id), - builder: (context, snapshot) { - if (!snapshot.hasData || snapshot.data! >= 1) { - return const SizedBox(); - } - - return Container( - color: - widget.uploadOverlayColor ?? - theme.colors.surfaceContainerLow.withValues( - alpha: 0.5, - ), - child: Center( - child: CircularProgressIndicator( - color: - widget.uploadIndicatorColor ?? - theme.colors.onSurface.withValues(alpha: 0.8), - strokeCap: StrokeCap.round, - value: snapshot.data, - ), - ), - ); - }, - ), if (timeAndStatus != null) Positioned.directional( textDirection: textDirection, @@ -319,86 +214,4 @@ class _FlyerChatImageMessageState extends State ), ); } - - ImageProvider get _targetProvider { - if (widget.customImageProvider != null) { - return widget.customImageProvider!; - } else { - final crossCache = context.read(); - return CachedNetworkImage( - widget.message.source, - crossCache, - headers: widget.headers, - ); - } - } -} - -/// A widget to display the message timestamp and status indicator over an image. -class TimeAndStatus extends StatelessWidget { - /// The time the message was created. - final DateTime? time; - - /// The status of the message. - final MessageStatus? status; - - /// Whether to display the timestamp. - final bool showTime; - - /// Whether to display the status indicator. - final bool showStatus; - - /// Background color for the time and status container. - final Color? backgroundColor; - - /// Text style for the time and status. - final TextStyle? textStyle; - - /// Creates a widget for displaying time and status over an image. - const TimeAndStatus({ - super.key, - required this.time, - this.status, - this.showTime = true, - this.showStatus = true, - this.backgroundColor, - this.textStyle, - }); - - @override - Widget build(BuildContext context) { - final timeFormat = context.watch(); - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - spacing: 2, - mainAxisSize: MainAxisSize.min, - children: [ - if (showTime && time != null) - Text(timeFormat.format(time!.toLocal()), style: textStyle), - if (showStatus && status != null) - if (status == MessageStatus.sending) - SizedBox( - width: 6, - height: 6, - child: CircularProgressIndicator( - color: textStyle?.color, - strokeWidth: 2, - ), - ) - else - Icon( - getIconForStatus(status!), - color: textStyle?.color, - size: 12, - ), - ], - ), - ); - } } diff --git a/packages/flyer_chat_image_message/lib/src/flyer_chat_multi_images_message.dart b/packages/flyer_chat_image_message/lib/src/flyer_chat_multi_images_message.dart new file mode 100644 index 000000000..388d00b5e --- /dev/null +++ b/packages/flyer_chat_image_message/lib/src/flyer_chat_multi_images_message.dart @@ -0,0 +1,292 @@ +import 'package:cross_cache/cross_cache.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:provider/provider.dart'; +import 'widgets/single_image_container.dart'; +import 'widgets/time_and_status.dart'; + +/// A widget that displays an image message. +/// +/// Uses [CachedNetworkImage] for efficient loading and caching. +/// Supports BlurHash and ThumbHash placeholders. +/// Optionally displays upload progress if the [ChatController] +/// implements [UploadProgressMixin]. +class FlyerChatMultiImagesMessage extends StatefulWidget { + /// The image message data model. + final ImageMessage message; + + /// The index of the message in the list. + final int index; + + /// Optional custom [ImageProvider] to use for loading the image. + /// If not provided, defaults to using [CachedNetworkImage] from `cross_cache` + /// with the `message.source`. + final ImageProvider? customImageProvider; + + /// Optional HTTP headers for authenticated image requests. + /// Commonly used for authorization tokens, e.g., {'Authorization': 'Bearer token'}. + /// Only used when [customImageProvider] is null. + final Map? headers; + + /// Border radius of the image container. + final BorderRadiusGeometry? borderRadius; + + /// Constraints for the image size. + final BoxConstraints constraints; + + /// An optional overlay widget to display on top of the image (e.g., NSFW content). + /// Requires `message.hasOverlay` to be true. + final Widget? overlay; + + /// Background color used while the placeholder is visible. + final Color? placeholderColor; + + /// Color of the overlay shown during image loading. + final Color? loadingOverlayColor; + + /// Color of the circular progress indicator shown during image loading. + final Color? loadingIndicatorColor; + + /// Color of the overlay shown during image upload. + final Color? uploadOverlayColor; + + /// Color of the circular progress indicator shown during image upload. + final Color? uploadIndicatorColor; + + /// Text style for the message timestamp and status. + final TextStyle? timeStyle; + + /// Background color for the timestamp and status display. + final Color? timeBackground; + + /// Whether to display the message timestamp. + final bool showTime; + + /// Whether to display the message status (sent, delivered, seen) for sent messages. + final bool showStatus; + + /// Position of the timestamp and status indicator relative to the image. + final TimeAndStatusPosition timeAndStatusPosition; + + /// Creates a widget to display an image message. + const FlyerChatMultiImagesMessage({ + super.key, + required this.message, + required this.index, + this.customImageProvider, + this.headers, + this.borderRadius, + this.constraints = const BoxConstraints(maxHeight: 300), + this.overlay, + this.placeholderColor, + this.loadingOverlayColor, + this.loadingIndicatorColor, + this.uploadOverlayColor, + this.uploadIndicatorColor, + this.timeStyle, + this.timeBackground, + this.showTime = true, + this.showStatus = true, + this.timeAndStatusPosition = TimeAndStatusPosition.end, + }); + + @override + // ignore: library_private_types_in_public_api + _FlyerChatMultiImagesMessageState createState() => + _FlyerChatMultiImagesMessageState(); +} + +/// State for [FlyerChatMultiImagesMessage]. +class _FlyerChatMultiImagesMessageState + extends State { + @override + Widget build(BuildContext context) { + final theme = context.watch(); + final isSentByMe = context.watch() == widget.message.authorId; + final textDirection = Directionality.of(context); + final timeAndStatus = + widget.showTime || (isSentByMe && widget.showStatus) + ? TimeAndStatus( + time: widget.message.time, + status: widget.message.status, + showTime: widget.showTime, + showStatus: isSentByMe && widget.showStatus, + backgroundColor: + widget.timeBackground ?? Colors.black.withValues(alpha: 0.6), + textStyle: + widget.timeStyle ?? + theme.typography.labelSmall.copyWith(color: Colors.white), + ) + : null; + + // TODO adapt this code when we decide how this widget is seeded + final count = 15; + + final containers = List.generate( + count, + (index) => SingleImageContainer( + source: 'https://picsum.photos/${200 + index * 10}/300?random=$index', + headers: widget.headers, + thumbhash: widget.message.thumbhash, + blurhash: widget.message.blurhash, + hasOverlay: widget.message.hasOverlay, + placeholderColor: widget.placeholderColor, + loadingOverlayColor: widget.loadingOverlayColor, + loadingIndicatorColor: widget.loadingIndicatorColor, + uploadOverlayColor: widget.uploadOverlayColor, + uploadIndicatorColor: widget.uploadIndicatorColor, + overlay: widget.overlay, + customImageProvider: widget.customImageProvider, + imageFit: count == 1 ? BoxFit.fill : BoxFit.cover, + uploadProgressId: widget.message.id, + ), + ); + + final imagesCount = containers.length; + + late Widget grid; + final spacing = 2.0; // TODO: make this dynamic + + if (imagesCount == 1) { + grid = + containers[0]; // TODO: We could use only one widget and use AspectRatio here. + } else if (imagesCount == 2) { + grid = Row( + spacing: spacing, + children: [ + Flexible(child: containers[0]), + Flexible(child: containers[1]), + ], + ); + } else if (imagesCount == 3) { + grid = Row( + spacing: spacing, + children: [ + Expanded( + flex: 2, + child: Column( + spacing: spacing, + children: [Flexible(child: containers[0])], + ), + ), + Expanded( + flex: 1, + child: Column( + spacing: spacing, + children: [ + Flexible(child: containers[1]), + Flexible(child: containers[2]), + ], + ), + ), + ], + ); + } else if (imagesCount == 4) { + grid = Column( + spacing: spacing, + children: [ + Expanded( + child: Row( + spacing: spacing, + children: [ + Flexible(child: containers[0]), + Flexible(child: containers[1]), + ], + ), + ), + Expanded( + child: Row( + spacing: spacing, + children: [ + Flexible(child: containers[2]), + Flexible(child: containers[3]), + ], + ), + ), + ], + ); + } else if (imagesCount > 4) { + final hasMore = imagesCount > 5; + final hasMoreCount = imagesCount - 5; + grid = Column( + mainAxisSize: MainAxisSize.max, + spacing: spacing, + children: [ + Expanded( + child: Row( + spacing: spacing, + children: [ + Flexible(child: containers[0]), + Flexible(child: containers[1]), + ], + ), + ), + Expanded( + child: Row( + spacing: spacing, + children: [ + Flexible(child: containers[2]), + Flexible(child: containers[3]), + Flexible( + child: Stack( + fit: StackFit.expand, + children: [ + containers[4], + if (hasMore) + Container( + color: Colors.black.withValues( + alpha: 0.5, + ), // TODO: allow color override + child: Center( + child: Text( + // TODO: should we count the overlayed image + '+$hasMoreCount', + // TODO: allow override + style: TextStyle( + color: Colors.white, + fontSize: 32, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ); + } + + return ClipRRect( + borderRadius: widget.borderRadius ?? theme.shape, + child: Container( + constraints: widget.constraints, + child: Stack( + fit: StackFit.expand, + children: [ + grid, + if (timeAndStatus != null) + Positioned.directional( + textDirection: textDirection, + bottom: 8, + end: + widget.timeAndStatusPosition == TimeAndStatusPosition.end || + widget.timeAndStatusPosition == + TimeAndStatusPosition.inline + ? 8 + : null, + start: + widget.timeAndStatusPosition == TimeAndStatusPosition.start + ? 8 + : null, + child: timeAndStatus, + ), + ], + ), + ), + ); + } +} diff --git a/packages/flyer_chat_image_message/lib/src/get_image_dimensions.dart b/packages/flyer_chat_image_message/lib/src/helpers/get_image_dimensions.dart similarity index 100% rename from packages/flyer_chat_image_message/lib/src/get_image_dimensions.dart rename to packages/flyer_chat_image_message/lib/src/helpers/get_image_dimensions.dart diff --git a/packages/flyer_chat_image_message/lib/src/widgets/single_image_container.dart b/packages/flyer_chat_image_message/lib/src/widgets/single_image_container.dart new file mode 100644 index 000000000..1d7c3257a --- /dev/null +++ b/packages/flyer_chat_image_message/lib/src/widgets/single_image_container.dart @@ -0,0 +1,205 @@ +import 'dart:convert'; + +import 'package:blurhash_dart/blurhash_dart.dart'; +import 'package:cross_cache/cross_cache.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:image/image.dart' show encodeJpg; +import 'package:provider/provider.dart'; +import 'package:thumbhash/thumbhash.dart' show rgbaToBmp, thumbHashToRGBA; + +class SingleImageContainer extends StatefulWidget { + const SingleImageContainer({ + super.key, + required this.source, + this.headers, + this.customImageProvider, + this.overlay, + this.hasOverlay, + this.placeholderColor, + this.loadingOverlayColor, + this.loadingIndicatorColor, + this.uploadOverlayColor, + this.uploadIndicatorColor, + this.thumbhash, + this.blurhash, + this.imageFit = BoxFit.fill, + this.uploadProgressId, + }); + final String source; + final Map? headers; + final ImageProvider? customImageProvider; + final Widget? overlay; + final bool? hasOverlay; + final Color? placeholderColor; + final Color? loadingOverlayColor; + final Color? loadingIndicatorColor; + final Color? uploadIndicatorColor; + final Color? uploadOverlayColor; + final String? thumbhash; + final String? blurhash; + final BoxFit imageFit; + final String? uploadProgressId; + + @override + State createState() => _SingleImageContainerState(); +} + +class _SingleImageContainerState extends State { + late final ChatController _chatController; + late ImageProvider _imageProvider; + ImageProvider? _placeholderProvider; + + @override + void initState() { + super.initState(); + + // TODO: Could be passed down? to avoid recomputing the placeholder + // already done to the the aspectRatio + if (widget.thumbhash?.isNotEmpty ?? false) { + final thumbhashBytes = base64.decode(base64.normalize(widget.thumbhash!)); + final rgbaImage = thumbHashToRGBA(thumbhashBytes); + final bmp = rgbaToBmp(rgbaImage); + _placeholderProvider = MemoryImage(bmp); + } else if (widget.blurhash?.isNotEmpty ?? false) { + final blurhash = BlurHash.decode(widget.blurhash!); + final image = blurhash.toImage(35, 20); + final jpg = encodeJpg(image); + _placeholderProvider = MemoryImage(jpg); + } + + _chatController = context.read(); + _imageProvider = _targetProvider; + } + + @override + void didUpdateWidget(covariant SingleImageContainer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.source != widget.source || + oldWidget.headers != widget.headers) { + final newImage = _targetProvider; + + precacheImage(newImage, context).then((_) { + if (mounted) { + _imageProvider = newImage; + } + }); + } + } + + @override + void dispose() { + _placeholderProvider?.evict(); + // Evicting the image on dispose will result in images flickering + // PaintingBinding.instance.imageCache.evict(_imageProvider); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = context.watch(); + + return Stack( + fit: StackFit.expand, + children: [ + _placeholderProvider != null + ? Image(image: _placeholderProvider!, fit: BoxFit.fill) + : Container( + color: + widget.placeholderColor ?? theme.colors.surfaceContainerLow, + ), + Image( + image: _imageProvider, + fit: widget.imageFit, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) { + return child; + } + + return Container( + color: + widget.loadingOverlayColor ?? + theme.colors.surfaceContainerLow.withValues(alpha: 0.5), + child: Center( + child: CircularProgressIndicator( + color: + widget.loadingIndicatorColor ?? + theme.colors.onSurface.withValues(alpha: 0.8), + strokeCap: StrokeCap.round, + value: + loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ), + ); + }, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + var content = child; + + if (widget.overlay != null && + widget.hasOverlay == true && + frame != null) { + content = Stack( + fit: StackFit.expand, + children: [child, widget.overlay!], + ); + } + + if (wasSynchronouslyLoaded) { + return content; + } + + return AnimatedOpacity( + duration: const Duration(milliseconds: 250), + opacity: frame == null ? 0 : 1, + curve: Curves.linearToEaseOut, + child: content, + ); + }, + ), + if (_chatController is UploadProgressMixin && + widget.uploadProgressId != null) + StreamBuilder( + stream: (_chatController as UploadProgressMixin).getUploadProgress( + widget.uploadProgressId!, + ), + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data! >= 1) { + return const SizedBox(); + } + + return Container( + color: + widget.uploadOverlayColor ?? + theme.colors.surfaceContainerLow.withValues(alpha: 0.5), + child: Center( + child: CircularProgressIndicator( + color: + widget.uploadIndicatorColor ?? + theme.colors.onSurface.withValues(alpha: 0.8), + strokeCap: StrokeCap.round, + value: snapshot.data, + ), + ), + ); + }, + ), + ], + ); + } + + ImageProvider get _targetProvider { + if (widget.customImageProvider != null) { + return widget.customImageProvider!; + } else { + final crossCache = context.read(); + return CachedNetworkImage( + widget.source, + crossCache, + headers: widget.headers, + ); + } + } +} diff --git a/packages/flyer_chat_image_message/lib/src/widgets/time_and_status.dart b/packages/flyer_chat_image_message/lib/src/widgets/time_and_status.dart new file mode 100644 index 000000000..ef39a93c3 --- /dev/null +++ b/packages/flyer_chat_image_message/lib/src/widgets/time_and_status.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:provider/provider.dart'; + +/// A widget to display the message timestamp and status indicator over an image. +class TimeAndStatus extends StatelessWidget { + /// The time the message was created. + final DateTime? time; + + /// The status of the message. + final MessageStatus? status; + + /// Whether to display the timestamp. + final bool showTime; + + /// Whether to display the status indicator. + final bool showStatus; + + /// Background color for the time and status container. + final Color? backgroundColor; + + /// Text style for the time and status. + final TextStyle? textStyle; + + /// Creates a widget for displaying time and status over an image. + const TimeAndStatus({ + super.key, + required this.time, + this.status, + this.showTime = true, + this.showStatus = true, + this.backgroundColor, + this.textStyle, + }); + + @override + Widget build(BuildContext context) { + final timeFormat = context.watch(); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + spacing: 2, + mainAxisSize: MainAxisSize.min, + children: [ + if (showTime && time != null) + Text(timeFormat.format(time!.toLocal()), style: textStyle), + if (showStatus && status != null) + if (status == MessageStatus.sending) + SizedBox( + width: 6, + height: 6, + child: CircularProgressIndicator( + color: textStyle?.color, + strokeWidth: 2, + ), + ) + else + Icon( + getIconForStatus(status!), + color: textStyle?.color, + size: 12, + ), + ], + ), + ); + } +}