From 6d81fe2d6aba3b1df1d5f5a167768275d48c3c0b Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Fri, 23 May 2025 16:17:33 +0200 Subject: [PATCH 01/32] Add empty package --- packages/flyer_chat_link_preview/LICENSE | 21 +++++++++++++++ .../analysis_options.yaml | 1 + packages/flyer_chat_link_preview/pubspec.yaml | 27 +++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 packages/flyer_chat_link_preview/LICENSE create mode 100644 packages/flyer_chat_link_preview/analysis_options.yaml create mode 100644 packages/flyer_chat_link_preview/pubspec.yaml diff --git a/packages/flyer_chat_link_preview/LICENSE b/packages/flyer_chat_link_preview/LICENSE new file mode 100644 index 000000000..5a17b5947 --- /dev/null +++ b/packages/flyer_chat_link_preview/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Oleksandr Demchenko + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/flyer_chat_link_preview/analysis_options.yaml b/packages/flyer_chat_link_preview/analysis_options.yaml new file mode 100644 index 000000000..f04c6cf0f --- /dev/null +++ b/packages/flyer_chat_link_preview/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/flyer_chat_link_preview/pubspec.yaml b/packages/flyer_chat_link_preview/pubspec.yaml new file mode 100644 index 000000000..bfe13ca66 --- /dev/null +++ b/packages/flyer_chat_link_preview/pubspec.yaml @@ -0,0 +1,27 @@ +name: flutter_link_previewer +description: > + Customizable link and URL preview extracted from the + provided text with the ability to render from the cache. Ideal + for chat applications. +version: 4.0.0 +homepage: https://flyer.chat +repository: https://github.com/flyerhq/flutter_chat_ui + +environment: + sdk: ">=3.7.0 <4.0.0" + flutter: ">=3.29.0" + +dependencies: + flutter: + sdk: flutter + flutter_chat_core: ^2.3.0 + html: ^0.15.6 + http: ">=0.13.6 <2.0.0" + meta: ">=1.8.0 <2.0.0" + url_launcher: ^6.3.1 + +dev_dependencies: + dart_code_metrics: ^5.7.5 + flutter_lints: ^2.0.2 + flutter_test: + sdk: flutter From 47680f9aa22e10b686f33a6fdfe247af55e6b7bc Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Fri, 23 May 2025 19:09:11 +0200 Subject: [PATCH 02/32] Add old lib utils --- .../lib/src/types.dart | 14 + .../lib/src/utils.dart | 269 ++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 packages/flyer_chat_link_preview/lib/src/types.dart create mode 100644 packages/flyer_chat_link_preview/lib/src/utils.dart diff --git a/packages/flyer_chat_link_preview/lib/src/types.dart b/packages/flyer_chat_link_preview/lib/src/types.dart new file mode 100644 index 000000000..932cd1c9e --- /dev/null +++ b/packages/flyer_chat_link_preview/lib/src/types.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +/// Represents the size object. +@immutable +class Size { + /// Creates [Size] from width and height. + const Size({required this.height, required this.width}); + + /// Height. + final double height; + + /// Width. + final double width; +} diff --git a/packages/flyer_chat_link_preview/lib/src/utils.dart b/packages/flyer_chat_link_preview/lib/src/utils.dart new file mode 100644 index 000000000..16ba04ba0 --- /dev/null +++ b/packages/flyer_chat_link_preview/lib/src/utils.dart @@ -0,0 +1,269 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart' hide Element; +import 'package:flutter_chat_core/flutter_chat_core.dart' + show LinkPreviewData, ImagePreviewData; +import 'package:html/dom.dart' show Document, Element; +import 'package:html/parser.dart' as parser show parse; +import 'package:http/http.dart' as http show get; + +import 'types.dart'; + +String _calculateUrl(String baseUrl, String? proxy) { + if (proxy != null) { + return '$proxy$baseUrl'; + } + + return baseUrl; +} + +String? _getMetaContent(Document document, String propertyValue) { + final meta = document.getElementsByTagName('meta'); + final element = meta.firstWhere( + (e) => e.attributes['property'] == propertyValue, + orElse: + () => meta.firstWhere( + (e) => e.attributes['name'] == propertyValue, + orElse: () => Element.tag(null), + ), + ); + + return element.attributes['content']?.trim(); +} + +bool _hasUTF8Charset(Document document) { + final emptyElement = Element.tag(null); + final meta = document.getElementsByTagName('meta'); + final element = meta.firstWhere( + (e) => e.attributes.containsKey('charset'), + orElse: () => emptyElement, + ); + if (element == emptyElement) return true; + return element.attributes['charset']!.toLowerCase() == 'utf-8'; +} + +String? _getTitle(Document document) { + final titleElements = document.getElementsByTagName('title'); + if (titleElements.isNotEmpty) return titleElements.first.text; + + return _getMetaContent(document, 'og:title') ?? + _getMetaContent(document, 'twitter:title') ?? + _getMetaContent(document, 'og:site_name'); +} + +String? _getDescription(Document document) => + _getMetaContent(document, 'og:description') ?? + _getMetaContent(document, 'description') ?? + _getMetaContent(document, 'twitter:description'); + +List _getImageUrls(Document document, String baseUrl) { + final meta = document.getElementsByTagName('meta'); + var attribute = 'content'; + var elements = + meta + .where( + (e) => + e.attributes['property'] == 'og:image' || + e.attributes['property'] == 'twitter:image', + ) + .toList(); + + if (elements.isEmpty) { + elements = document.getElementsByTagName('img'); + attribute = 'src'; + } + + return elements.fold>([], (previousValue, element) { + final actualImageUrl = _getActualImageUrl( + baseUrl, + element.attributes[attribute]?.trim(), + ); + + return actualImageUrl != null + ? [...previousValue, actualImageUrl] + : previousValue; + }); +} + +String? _getActualImageUrl(String baseUrl, String? imageUrl) { + if (imageUrl == null || imageUrl.isEmpty || imageUrl.startsWith('data')) { + return null; + } + + if (imageUrl.contains('.svg') || imageUrl.contains('.gif')) return null; + + if (imageUrl.startsWith('//')) imageUrl = 'https:$imageUrl'; + + if (!imageUrl.startsWith('http')) { + if (baseUrl.endsWith('/') && imageUrl.startsWith('/')) { + imageUrl = '${baseUrl.substring(0, baseUrl.length - 1)}$imageUrl'; + } else if (!baseUrl.endsWith('/') && !imageUrl.startsWith('/')) { + imageUrl = '$baseUrl/$imageUrl'; + } else { + imageUrl = '$baseUrl$imageUrl'; + } + } + + return imageUrl; +} + +Future _getImageSize(String url) { + final completer = Completer(); + final stream = Image.network(url).image.resolve(ImageConfiguration.empty); + late ImageStreamListener streamListener; + + void onError(Object error, StackTrace? stackTrace) { + completer.completeError(error, stackTrace); + } + + void listener(ImageInfo info, bool _) { + if (!completer.isCompleted) { + completer.complete( + Size( + height: info.image.height.toDouble(), + width: info.image.width.toDouble(), + ), + ); + } + stream.removeListener(streamListener); + } + + streamListener = ImageStreamListener(listener, onError: onError); + + stream.addListener(streamListener); + return completer.future; +} + +Future _getImageSizeFromBytes(Uint8List bytes) async { + final image = await decodeImageFromList(bytes); + return Size(height: image.height.toDouble(), width: image.width.toDouble()); +} + +Future _getBiggestImageUrl( + List imageUrls, + String? proxy, +) async { + if (imageUrls.length > 5) { + imageUrls.removeRange(5, imageUrls.length); + } + + var currentUrl = imageUrls[0]; + var currentArea = 0.0; + + await Future.forEach(imageUrls, (String url) async { + final size = await _getImageSize(_calculateUrl(url, proxy)); + final area = size.width * size.height; + if (area > currentArea) { + currentArea = area; + currentUrl = _calculateUrl(url, proxy); + } + }); + + return currentUrl; +} + +/// Parses provided text and returns [PreviewData] for the first found link. +Future getLinkPreviewData( + String text, { + String? proxy, + Duration? requestTimeout, + String? userAgent, +}) async { + String? previewDataDescription; + String? previewDataTitle; + String? previewDataUrl; + ImagePreviewData? previewDataImage; + + try { + final emailRegexp = RegExp(regexEmail, caseSensitive: false); + final textWithoutEmails = + text.replaceAllMapped(emailRegexp, (match) => '').trim(); + if (textWithoutEmails.isEmpty) return null; + + final urlRegexp = RegExp(regexLink, caseSensitive: false); + final matches = urlRegexp.allMatches(textWithoutEmails); + if (matches.isEmpty) return null; + + var url = textWithoutEmails.substring( + matches.first.start, + matches.first.end, + ); + + if (!url.toLowerCase().startsWith('http')) { + url = 'https://$url'; + } + previewDataUrl = _calculateUrl(url, proxy); + final uri = Uri.parse(previewDataUrl); + final response = await http + .get(uri, headers: {'User-Agent': userAgent ?? 'WhatsApp/2'}) + .timeout(requestTimeout ?? const Duration(seconds: 5)); + + final imageRegexp = RegExp(regexImageContentType); + + if (imageRegexp.hasMatch(response.headers['content-type'] ?? '')) { + final imageSize = await _getImageSizeFromBytes(response.bodyBytes); + return LinkPreviewData( + link: url, + image: ImagePreviewData( + url: url, + height: imageSize.height, + width: imageSize.width, + ), + ); + } + + final document = parser.parse(utf8.decode(response.bodyBytes)); + if (!_hasUTF8Charset(document)) { + return LinkPreviewData(link: url); + } + + final title = _getTitle(document); + if (title != null) { + previewDataTitle = title.trim(); + } + + final description = _getDescription(document); + if (description != null) { + previewDataDescription = description.trim(); + } + + final imageUrls = _getImageUrls(document, url); + + Size imageSize; + String previewDataImageUrl; + + if (imageUrls.isNotEmpty) { + previewDataImageUrl = + imageUrls.length == 1 + ? _calculateUrl(imageUrls[0], proxy) + : await _getBiggestImageUrl(imageUrls, proxy); + + imageSize = await _getImageSize(previewDataImageUrl); + previewDataImage = ImagePreviewData( + url: previewDataImageUrl, + width: imageSize.width, + height: imageSize.height, + ); + } + return LinkPreviewData( + link: previewDataUrl, + title: previewDataTitle, + description: previewDataDescription, + image: previewDataImage, + ); + } catch (e) { + return null; + } +} + +/// Regex to check if text is email. +const regexEmail = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'; + +/// Regex to check if content type is an image. +const regexImageContentType = r'image\/*'; + +/// Regex to find all links in the text. +const regexLink = + r'((http|ftp|https):\/\/)?([\w_-]+(?:(?:\.[\w_-]*[a-zA-Z_][\w_-]*)+))([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])?[^\.\s]'; From b9bd63f518502dfec206dba25bc424cf541fe4ce Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Fri, 23 May 2025 19:09:30 +0200 Subject: [PATCH 03/32] Bump dependencies, rename --- packages/flyer_chat_link_preview/pubspec.yaml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/flyer_chat_link_preview/pubspec.yaml b/packages/flyer_chat_link_preview/pubspec.yaml index bfe13ca66..5211c9380 100644 --- a/packages/flyer_chat_link_preview/pubspec.yaml +++ b/packages/flyer_chat_link_preview/pubspec.yaml @@ -1,4 +1,4 @@ -name: flutter_link_previewer +name: flyer_chat_link_preview description: > Customizable link and URL preview extracted from the provided text with the ability to render from the cache. Ideal @@ -16,12 +16,10 @@ dependencies: sdk: flutter flutter_chat_core: ^2.3.0 html: ^0.15.6 - http: ">=0.13.6 <2.0.0" - meta: ">=1.8.0 <2.0.0" + http: ^1.4.0 url_launcher: ^6.3.1 dev_dependencies: - dart_code_metrics: ^5.7.5 - flutter_lints: ^2.0.2 + flutter_lints: ^5.0.0 flutter_test: sdk: flutter From e5d61a85a5a671e8d2381f934d415e49d60a29b5 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Fri, 23 May 2025 19:14:19 +0200 Subject: [PATCH 04/32] Add new preview widget --- .../lib/flyer_chat_link_preview.dart | 2 + .../lib/src/widgets/link_preview.dart | 404 ++++++++++++++++++ 2 files changed, 406 insertions(+) create mode 100644 packages/flyer_chat_link_preview/lib/flyer_chat_link_preview.dart create mode 100644 packages/flyer_chat_link_preview/lib/src/widgets/link_preview.dart diff --git a/packages/flyer_chat_link_preview/lib/flyer_chat_link_preview.dart b/packages/flyer_chat_link_preview/lib/flyer_chat_link_preview.dart new file mode 100644 index 000000000..4f2d2c5ac --- /dev/null +++ b/packages/flyer_chat_link_preview/lib/flyer_chat_link_preview.dart @@ -0,0 +1,2 @@ +export 'src/utils.dart' show getLinkPreviewData; +export 'src/widgets/link_preview.dart'; diff --git a/packages/flyer_chat_link_preview/lib/src/widgets/link_preview.dart b/packages/flyer_chat_link_preview/lib/src/widgets/link_preview.dart new file mode 100644 index 000000000..cb2d9e224 --- /dev/null +++ b/packages/flyer_chat_link_preview/lib/src/widgets/link_preview.dart @@ -0,0 +1,404 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart' show LinkPreviewData; +import 'package:url_launcher/url_launcher.dart'; + +import '../utils.dart' show getLinkPreviewData; + +/// A widget that renders text with highlighted links. +/// Eventually unwraps to the full preview of the first found link +/// if the parsing was successful. +@immutable +class LinkPreview extends StatefulWidget { + /// Creates [LinkPreview]. + const LinkPreview({ + super.key, + required this.text, + this.linkPreviewData, + required this.onLinkPreviewDataFetched, + this.corsProxy, + this.requestTimeout, + this.userAgent, + this.enableAnimation = false, + this.animationDuration, + this.titleTextStyle, + this.descriptionTextStyle, + this.imageBuilder, + this.onLinkPressed, + this.openOnPreviewImageTap = true, + this.openOnPreviewTitleTap = true, + this.hideImage = false, + this.hideTitle = false, + this.hideDescription = false, + this.forcedLayout, + this.insidePadding = const EdgeInsets.all(4), + this.outsidePadding = const EdgeInsets.symmetric(vertical: 2), + this.backgroundColor, + this.sideBorderColor, + this.sideBorderWidth = 4, + this.borderRadius = 4, + this.maxImageHeight = 150, + }); + + /// Text used for parsing. + final String text; + + /// Pass saved [LinkPreviewData] here so [LinkPreview] would not fetch preview + /// data again. + final LinkPreviewData? linkPreviewData; + + /// Callback which is called when [LinkPreviewData] was successfully parsed. + /// Use it to save [LinkPreviewData] to the state and pass it back + /// to the [LinkPreview.LinkPreviewData] so the [LinkPreview] would not fetch + /// preview data again. + final void Function(LinkPreviewData?) onLinkPreviewDataFetched; + + /// CORS proxy to make more previews work on web. Not tested. + final String? corsProxy; + + /// Request timeout after which the request will be cancelled. Defaults to 5 seconds. + final Duration? requestTimeout; + + /// User agent to send as GET header when requesting link preview url. + final String? userAgent; + + /// Enables expand animation. Default value is false. + final bool? enableAnimation; + + /// Expand animation duration. + final Duration? animationDuration; + + /// Style of preview's title. + final TextStyle? titleTextStyle; + + /// Style of preview's description. + final TextStyle? descriptionTextStyle; + + /// Function that allows you to build a custom image. + final Widget Function(String)? imageBuilder; + + /// Custom link press handler. + final void Function(String)? onLinkPressed; + + /// Open the link when the link preview image is tapped. Defaults to true. + final bool openOnPreviewImageTap; + + /// Open the link when the link preview title/description is tapped. Defaults to true. + final bool openOnPreviewTitleTap; + + /// Hides image data from the preview. + final bool hideImage; + + /// Hides title data from the preview. + final bool hideTitle; + + /// Hides description data from the preview. + final bool hideDescription; + + /// Force the link image to be displayed on the side of the preview. + final LinkPreviewImagePosition? forcedLayout; + + /// Padding inside the link preview widget. + final EdgeInsets insidePadding; + + /// Margin around the link preview widget. + final EdgeInsets outsidePadding; + + /// Background color. + final Color? backgroundColor; + + /// Preview border color. + final Color? sideBorderColor; + + /// Preview border width. + final double sideBorderWidth; + + /// Preview border radius. + final double borderRadius; + + /// Max image height. + final double maxImageHeight; + + @override + State createState() => _LinkPreviewState(); +} + +class _LinkPreviewState extends State + with SingleTickerProviderStateMixin { + bool isFetchingLinkPreviewData = false; + bool shouldAnimate = false; + LinkPreviewData? _linkPreviewData; + + late final Animation _animation; + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + + _linkPreviewData = widget.linkPreviewData; + + _controller = AnimationController( + duration: widget.animationDuration ?? const Duration(milliseconds: 300), + vsync: this, + ); + + _animation = CurvedAnimation( + parent: _controller, + curve: Curves.easeOutQuad, + ); + + didUpdateWidget(widget); + } + + Widget _animated(Widget child) => SizeTransition( + axis: Axis.vertical, + axisAlignment: -1, + sizeFactor: _animation, + child: child, + ); + + Widget _containerWidget({required LinkPreviewImagePosition imagePosition}) { + final shouldAnimate = widget.enableAnimation == true; + + final preview = Stack( + children: [ + Container( + margin: widget.outsidePadding, + decoration: BoxDecoration( + color: widget.backgroundColor ?? Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(widget.borderRadius), + ), + child: Padding( + padding: widget.insidePadding, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox(width: widget.borderRadius), + Flexible( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (_shouldShowTitle()) + Flexible( + fit: FlexFit.loose, + child: _titleWidget(_linkPreviewData!.title!), + ), + if (_shouldShowDescription()) + Flexible( + fit: FlexFit.loose, + child: _descriptionWidget( + widget.linkPreviewData!.description!, + ), + ), + if (imagePosition == LinkPreviewImagePosition.bottom) + _imageWidget( + imageUrl: _linkPreviewData!.image!.url, + linkUrl: _linkPreviewData!.link, + ), + ], + ), + ), + if (imagePosition == LinkPreviewImagePosition.side) ...[ + const SizedBox(width: 4), + Flexible( + flex: 1, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _imageWidget( + imageUrl: _linkPreviewData!.image!.url, + linkUrl: _linkPreviewData!.link, + ), + ], + ), + ), + ], + ], + ), + ), + ), + Positioned( + left: 0, + top: 0, + bottom: 0, + child: Container( + margin: widget.outsidePadding, + width: widget.borderRadius, + decoration: BoxDecoration( + color: + widget.sideBorderColor ?? + Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(widget.borderRadius), + bottomLeft: Radius.circular(widget.borderRadius), + ), + ), + ), + ), + ], + ); + + return shouldAnimate ? _animated(preview) : preview; + } + + Widget _titleWidget(String title) { + final style = + widget.descriptionTextStyle ?? + const TextStyle(fontWeight: FontWeight.bold); + return Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: style, + ); + } + + Widget _descriptionWidget(String description) => Text( + description, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: widget.titleTextStyle, + ); + + Widget _imageWidget({required String imageUrl, required String linkUrl}) => + GestureDetector( + onTap: widget.openOnPreviewImageTap ? () => _onOpen(linkUrl) : null, + child: Center( + child: LayoutBuilder( + builder: (context, constraints) { + return ClipRRect( + borderRadius: BorderRadius.circular(widget.borderRadius), + child: Container( + constraints: BoxConstraints( + maxHeight: constraints.maxWidth, + maxWidth: constraints.maxWidth, + ), + child: + widget.imageBuilder != null + ? widget.imageBuilder!(imageUrl) + : Image.network(imageUrl, fit: BoxFit.contain), + ), + ); + }, + ), + ), + ); + + Future _fetchData(String text) async { + setState(() { + isFetchingLinkPreviewData = true; + }); + + final linkPreviewData = await getLinkPreviewData( + text, + proxy: widget.corsProxy, + requestTimeout: widget.requestTimeout, + userAgent: widget.userAgent, + ); + await _handleLinkPreviewDataFetched(linkPreviewData); + return linkPreviewData; + } + + Future _handleLinkPreviewDataFetched( + LinkPreviewData? linkPreviewData, + ) async { + await Future.delayed( + widget.animationDuration ?? const Duration(milliseconds: 300), + ); + + if (mounted) { + widget.onLinkPreviewDataFetched(linkPreviewData); + setState(() { + isFetchingLinkPreviewData = false; + }); + } + } + + bool _hasDisplayableData() => + _shouldShowDescription() || _shouldShowTitle() || _shouldShowImage(); + + bool _hasOnlyImage() => + _shouldShowImage() && !_shouldShowTitle() && !_shouldShowDescription(); + + bool _shouldShowImage() => + _linkPreviewData?.image != null && !widget.hideImage; + bool _shouldShowTitle() => + _linkPreviewData?.title != null && !widget.hideTitle; + bool _shouldShowDescription() => + _linkPreviewData?.description != null && !widget.hideDescription; + + Future _onOpen(String url) async { + if (widget.onLinkPressed != null) { + widget.onLinkPressed!(url); + } else { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + } + + @override + void didUpdateWidget(covariant LinkPreview oldWidget) { + super.didUpdateWidget(oldWidget); + + if (!isFetchingLinkPreviewData && widget.linkPreviewData == null) { + _fetchData(widget.text); + } + + if (widget.linkPreviewData != null && oldWidget.linkPreviewData == null) { + _linkPreviewData = widget.linkPreviewData; + setState(() { + shouldAnimate = true; + }); + _controller.reset(); + _controller.forward(); + } else if (widget.linkPreviewData != null) { + setState(() { + shouldAnimate = false; + }); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final linkPreviewData = _linkPreviewData; + + if (linkPreviewData != null && _hasDisplayableData()) { + final aspectRatio = + linkPreviewData.image == null + ? null + : linkPreviewData.image!.width / linkPreviewData.image!.height; + + final useSideImage = + widget.forcedLayout == LinkPreviewImagePosition.side || + (widget.forcedLayout != LinkPreviewImagePosition.bottom && + aspectRatio == 1 && + !_hasOnlyImage()); + + return _containerWidget( + imagePosition: + useSideImage + ? LinkPreviewImagePosition.side + : LinkPreviewImagePosition.bottom, + ); + } else { + // While loading, we don't want to show anything + return const SizedBox.shrink(); + } + } +} + +enum LinkPreviewImagePosition { bottom, side } From 7911ff3b676ac049791e1e52fe4f751e1e5678a8 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Fri, 23 May 2025 19:14:45 +0200 Subject: [PATCH 05/32] Adapt base text widgets to the new intended layout --- .../lib/src/simple_text_message.dart | 83 +++++++++--------- .../lib/src/flyer_chat_text_message.dart | 85 +++++++++++-------- 2 files changed, 91 insertions(+), 77 deletions(-) diff --git a/packages/flutter_chat_ui/lib/src/simple_text_message.dart b/packages/flutter_chat_ui/lib/src/simple_text_message.dart index bd1414610..1495c4ae2 100644 --- a/packages/flutter_chat_ui/lib/src/simple_text_message.dart +++ b/packages/flutter_chat_ui/lib/src/simple_text_message.dart @@ -143,9 +143,6 @@ class SimpleTextMessage extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (linkPreviewWidget != null && - linkPreviewPosition == LinkPreviewPosition.top) - linkPreviewWidget, Container( padding: _isOnlyEmoji @@ -159,11 +156,9 @@ class SimpleTextMessage extends StatelessWidget { textContent: textContent, timeAndStatus: timeAndStatus, textStyle: textStyle, + linkPreviewWidget: linkPreviewWidget, ), ), - if (linkPreviewWidget != null && - linkPreviewPosition == LinkPreviewPosition.bottom) - linkPreviewWidget, ], ), ), @@ -175,50 +170,58 @@ class SimpleTextMessage extends StatelessWidget { required Widget textContent, TimeAndStatus? timeAndStatus, TextStyle? textStyle, + Widget? linkPreviewWidget, }) { if (timeAndStatus == null) { return textContent; } final textDirection = Directionality.of(context); + final effectiveLinkPreviewPosition = + linkPreviewWidget != null + ? linkPreviewPosition + : LinkPreviewPosition.none; - switch (timeAndStatusPosition) { - case TimeAndStatusPosition.start: - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [textContent, timeAndStatus], - ); - case TimeAndStatusPosition.inline: - return Row( + return Stack( + children: [ + Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Flexible(child: textContent), - const SizedBox(width: 4), - Padding( - padding: timeAndStatusPositionInlineInsets ?? EdgeInsets.zero, - child: timeAndStatus, - ), + if (effectiveLinkPreviewPosition == LinkPreviewPosition.top) + linkPreviewWidget!, + timeAndStatusPosition == TimeAndStatusPosition.inline + ? Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible(child: textContent), + SizedBox(width: 4), + Padding( + padding: + timeAndStatusPositionInlineInsets ?? EdgeInsets.zero, + child: timeAndStatus, + ), + ], + ) + : textContent, + if (effectiveLinkPreviewPosition == LinkPreviewPosition.bottom) + linkPreviewWidget!, + if (timeAndStatusPosition == TimeAndStatusPosition.end) + SizedBox(height: textStyle?.lineHeight ?? 0), ], - ); - case TimeAndStatusPosition.end: - return Stack( - children: [ - Padding( - padding: EdgeInsets.only(bottom: textStyle?.lineHeight ?? 0), - child: textContent, - ), - Opacity(opacity: 0, child: timeAndStatus), - Positioned.directional( - textDirection: textDirection, - end: 0, - bottom: 0, - child: timeAndStatus, - ), - ], - ); - } + ), + if (timeAndStatusPosition != TimeAndStatusPosition.inline) + Positioned.directional( + textDirection: textDirection, + end: timeAndStatusPosition == TimeAndStatusPosition.end ? 0 : null, + start: + timeAndStatusPosition == TimeAndStatusPosition.start ? 0 : null, + bottom: 0, + child: timeAndStatus, + ), + ], + ); } Color? _resolveBackgroundColor(bool isSentByMe, _LocalTheme theme) { diff --git a/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart b/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart index bb075ccb6..200962126 100644 --- a/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart +++ b/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart @@ -181,11 +181,12 @@ class FlyerChatTextMessage extends StatelessWidget { textContent: textContent, timeAndStatus: timeAndStatus, paragraphStyle: paragraphStyle, + linkPreviewWidget: linkPreviewWidget, ), ), - if (linkPreviewWidget != null && - linkPreviewPosition == LinkPreviewPosition.bottom) - linkPreviewWidget, + // if (linkPreviewWidget != null && + // linkPreviewPosition == LinkPreviewPosition.bottom) + // linkPreviewWidget, ], ), ), @@ -197,50 +198,60 @@ class FlyerChatTextMessage extends StatelessWidget { required Widget textContent, TimeAndStatus? timeAndStatus, TextStyle? paragraphStyle, + Widget? linkPreviewWidget, }) { if (timeAndStatus == null) { return textContent; } final textDirection = Directionality.of(context); + final effectiveLinkPreviewPosition = + linkPreviewWidget != null + ? linkPreviewPosition + : LinkPreviewPosition.none; - switch (timeAndStatusPosition) { - case TimeAndStatusPosition.start: - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [textContent, timeAndStatus], - ); - case TimeAndStatusPosition.inline: - return Row( + return Stack( + children: [ + Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Flexible(child: textContent), - const SizedBox(width: 4), - Padding( - padding: timeAndStatusPositionInlineInsets ?? EdgeInsets.zero, - child: timeAndStatus, - ), - ], - ); - case TimeAndStatusPosition.end: - return Stack( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: EdgeInsets.only(bottom: paragraphStyle?.lineHeight ?? 0), - child: textContent, - ), - Opacity(opacity: 0, child: timeAndStatus), - Positioned.directional( - textDirection: textDirection, - end: 0, - bottom: 0, - child: timeAndStatus, - ), + if (effectiveLinkPreviewPosition == LinkPreviewPosition.top) + linkPreviewWidget!, + timeAndStatusPosition == TimeAndStatusPosition.inline + ? Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible(child: textContent), + SizedBox(width: 4), + Padding( + padding: + timeAndStatusPositionInlineInsets ?? EdgeInsets.zero, + child: timeAndStatus, + ), + ], + ) + : textContent, + if (effectiveLinkPreviewPosition == LinkPreviewPosition.bottom) + linkPreviewWidget!, + if (timeAndStatusPosition == TimeAndStatusPosition.end) + SizedBox(height: paragraphStyle?.lineHeight ?? 0), + if (timeAndStatusPosition == TimeAndStatusPosition.start) + timeAndStatus, ], - ); - } + ), + if (timeAndStatusPosition != TimeAndStatusPosition.inline) + Positioned.directional( + textDirection: textDirection, + end: timeAndStatusPosition == TimeAndStatusPosition.end ? 0 : null, + start: + timeAndStatusPosition == TimeAndStatusPosition.start ? 0 : null, + bottom: 0, + child: timeAndStatus, + ), + ], + ); } Color? _resolveBackgroundColor(bool isSentByMe, _LocalTheme theme) { From 64c10da77a26f38d04322742d1b60a20222f4869 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Fri, 23 May 2025 19:15:06 +0200 Subject: [PATCH 06/32] Add example --- examples/flyer_chat/lib/link_preview.dart | 211 ++++++++++++++++++++++ examples/flyer_chat/lib/main.dart | 12 ++ examples/flyer_chat/pubspec.yaml | 4 +- 3 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 examples/flyer_chat/lib/link_preview.dart diff --git a/examples/flyer_chat/lib/link_preview.dart b/examples/flyer_chat/lib/link_preview.dart new file mode 100644 index 000000000..865ee1e24 --- /dev/null +++ b/examples/flyer_chat/lib/link_preview.dart @@ -0,0 +1,211 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:flutter_chat_ui/flutter_chat_ui.dart'; +import 'package:flyer_chat_link_preview/flyer_chat_link_preview.dart'; +import 'package:uuid/uuid.dart'; + +class LinkPreviewExample extends StatefulWidget { + final Dio dio; + + const LinkPreviewExample({super.key, required this.dio}); + + @override + LinkPreviewExampleState createState() => LinkPreviewExampleState(); +} + +class LinkPreviewExampleState extends State { + final _chatController = InMemoryChatController(); + + final _currentUser = const User( + id: 'me', + imageSource: 'https://picsum.photos/id/65/200/200', + ); + + final metadataLinkPreviewFetchedKey = 'linkPreviewDataFetched'; + + @override + void dispose() { + _chatController.dispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + _chatController.setMessages([ + Message.text( + id: 'not-fetched', + authorId: _currentUser.id, + text: 'https://flyer.chat/ Not fetchet yet', + createdAt: DateTime.now().toUtc().subtract(const Duration(hours: 1)), + ), + Message.text( + id: 'wont-fetch', + authorId: _currentUser.id, + text: 'https://flyer.chat/ Will not fetch', + createdAt: DateTime.now().toUtc().subtract(const Duration(hours: 2)), + metadata: {metadataLinkPreviewFetchedKey: true}, + ), + Message.text( + id: 'complete', + authorId: _currentUser.id, + text: 'https://flyer.chat/ Already fetched', + createdAt: DateTime.now().toUtc().subtract(const Duration(hours: 3)), + linkPreviewData: LinkPreviewData( + link: 'https://flyer.chat/', + description: + 'Open-source chat SDK for Flutter and React Native. Build fast, real-time apps and AI agents with a high-performance, customizable, cross-platform UI.', + image: ImagePreviewData( + url: 'https://flyer.chat/og-image.png', + width: 1200.0, + height: 630.0, + ), + title: 'Flyer Chat | Ship faster with a go-to chat SDK', + ), + ), + + Message.text( + id: 'display-only-image', + authorId: _currentUser.id, + text: 'https://flyer.chat/ Display only the image', + createdAt: DateTime.now().toUtc().subtract(const Duration(hours: 4)), + linkPreviewData: LinkPreviewData( + link: 'https://flyer.chat/', + description: + 'Open-source chat SDK for Flutter and React Native. Build fast, real-time apps and AI agents with a high-performance, customizable, cross-platform UI.', + image: ImagePreviewData( + url: 'https://flyer.chat/og-image.png', + width: 1200.0, + height: 630.0, + ), + title: 'Flyer Chat | Ship faster with a go-to chat SDK', + ), + ), + Message.text( + id: 'fake-square', + authorId: _currentUser.id, + text: 'https://flyer.chat/ Fake square image', + createdAt: DateTime.now().toUtc().subtract(const Duration(hours: 5)), + linkPreviewData: LinkPreviewData( + link: 'https://flyer.chat/', + description: + 'Open-source chat SDK for Flutter and React Native. Build fast, real-time apps and AI agents with a high-performance, customizable, cross-platform UI.', + image: ImagePreviewData( + url: 'https://flyer.chat/og-image.png', + width: 1200.0, + height: 1200.0, + ), + title: 'Flyer Chat | Ship faster with a go-to chat SDK', + ), + ), + Message.text( + id: 'image-content', + authorId: _currentUser.id, + text: 'https://flyer.chat/og-image.png This is an image link', + createdAt: DateTime.now().toUtc().subtract(const Duration(hours: 6)), + ), + Message.text( + id: 'short one', + authorId: _currentUser.id, + text: 'Fake short one', + createdAt: DateTime.now().toUtc().subtract(const Duration(hours: 5)), + linkPreviewData: LinkPreviewData( + link: 'https://flyer.chat/', + description: 'Open-source chat SDK', + image: ImagePreviewData( + url: 'https://flyer.chat/og-image.png', + width: 1200.0, + height: 1200.0, + ), + title: 'Flyer Chat', + ), + ), + ]); + } + + void _addItem(String? text) async { + if (text == null || text.isEmpty) { + return; + } + + final message = Message.text( + id: Uuid().v4(), + authorId: _currentUser.id, + text: text, + createdAt: DateTime.now().toUtc().subtract(const Duration(seconds: 1)), + ); + + if (mounted) { + await _chatController.insertMessage(message); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final chatTheme = + theme.brightness == Brightness.dark + ? ChatTheme.dark() + : ChatTheme.light(); + + return Scaffold( + appBar: AppBar(title: const Text('LinkPreview')), + body: Chat( + backgroundColor: Colors.transparent, + builders: Builders( + linkPreviewBuilder: (context, message) { + final isLinkPreviewDataFetched = + message.metadata?.containsKey(metadataLinkPreviewFetchedKey) ?? + false; + + // It's up to you to (optionally) implement the logic to avoid every + // client to eventually refetch the preview data + // + // For example, you can use a metadata to indicate if the preview + // was already fetched (and null). + // + // You could also store a data and implement some retry/refresh logic. + // + + if (message.linkPreviewData != null || !isLinkPreviewDataFetched) { + return LinkPreview( + text: message.text, + linkPreviewData: message.linkPreviewData, + hideDescription: message.id == 'display-only-image', + hideTitle: message.id == 'display-only-image', + backgroundColor: Colors.white.withAlpha(180), + sideBorderColor: Colors.white, + onLinkPreviewDataFetched: (linkPreviewData) { + _chatController.updateMessage( + message, + message.copyWith( + metadata: { + ...message.metadata ?? {}, + metadataLinkPreviewFetchedKey: true, + }, + linkPreviewData: linkPreviewData, + ), + ); + }, + ); + } + return null; + }, + ), + onMessageSend: (text) { + _addItem(text); + }, + chatController: _chatController, + currentUserId: _currentUser.id, + + resolveUser: + (id) => Future.value(switch (id) { + 'me' => _currentUser, + _ => null, + }), + theme: chatTheme, + ), + ); + } +} diff --git a/examples/flyer_chat/lib/main.dart b/examples/flyer_chat/lib/main.dart index e1b10b884..0c15924f1 100644 --- a/examples/flyer_chat/lib/main.dart +++ b/examples/flyer_chat/lib/main.dart @@ -12,6 +12,7 @@ import 'api_get_initial_messages.dart'; import 'basic.dart'; import 'gemini.dart'; import 'local.dart'; +import 'link_preview.dart'; import 'pagination.dart'; void main() async { @@ -283,6 +284,17 @@ class _FlyerChatHomePageState extends State { }, child: const Text('basic'), ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => LinkPreviewExample(dio: _dio), + ), + ); + }, + child: const Text('link preview'), + ), ], ), ), diff --git a/examples/flyer_chat/pubspec.yaml b/examples/flyer_chat/pubspec.yaml index bb97f1f50..b364edd6a 100644 --- a/examples/flyer_chat/pubspec.yaml +++ b/examples/flyer_chat/pubspec.yaml @@ -2,7 +2,7 @@ name: flyer_chat description: "A new Flutter project." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -42,6 +42,7 @@ dependencies: flutter_lorem: ^2.0.0 flyer_chat_file_message: ^2.2.2 flyer_chat_image_message: ^2.1.12 + flyer_chat_link_preview: ^4.0.0 flyer_chat_system_message: ^2.1.10 flyer_chat_text_message: ^2.3.2 flyer_chat_text_stream_message: ^2.2.2 @@ -76,7 +77,6 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: - # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. From 2d2ae797e0b0058c3b0fef729ee48f7bef9eb54a Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Sat, 24 May 2025 08:36:33 +0200 Subject: [PATCH 07/32] Add more constraints options --- .../lib/src/widgets/link_preview.dart | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/flyer_chat_link_preview/lib/src/widgets/link_preview.dart b/packages/flyer_chat_link_preview/lib/src/widgets/link_preview.dart index cb2d9e224..2d16a46b5 100644 --- a/packages/flyer_chat_link_preview/lib/src/widgets/link_preview.dart +++ b/packages/flyer_chat_link_preview/lib/src/widgets/link_preview.dart @@ -21,7 +21,9 @@ class LinkPreview extends StatefulWidget { this.enableAnimation = false, this.animationDuration, this.titleTextStyle, + this.maxTitleLines = 2, this.descriptionTextStyle, + this.maxDescriptionLines = 3, this.imageBuilder, this.onLinkPressed, this.openOnPreviewImageTap = true, @@ -37,6 +39,8 @@ class LinkPreview extends StatefulWidget { this.sideBorderWidth = 4, this.borderRadius = 4, this.maxImageHeight = 150, + this.maxWidth, + this.maxHeight, }); /// Text used for parsing. @@ -70,9 +74,15 @@ class LinkPreview extends StatefulWidget { /// Style of preview's title. final TextStyle? titleTextStyle; + /// Maximum number of lines for the title text. + final int maxTitleLines; + /// Style of preview's description. final TextStyle? descriptionTextStyle; + /// Maximum number of lines for the description text. + final int maxDescriptionLines; + /// Function that allows you to build a custom image. final Widget Function(String)? imageBuilder; @@ -118,6 +128,12 @@ class LinkPreview extends StatefulWidget { /// Max image height. final double maxImageHeight; + /// Max width. + final double? maxWidth; + + /// Max height. + final double? maxHeight; + @override State createState() => _LinkPreviewState(); } @@ -163,6 +179,10 @@ class _LinkPreviewState extends State final preview = Stack( children: [ Container( + constraints: BoxConstraints( + maxWidth: widget.maxWidth ?? double.infinity, + maxHeight: widget.maxHeight ?? double.infinity, + ), margin: widget.outsidePadding, decoration: BoxDecoration( color: widget.backgroundColor ?? Theme.of(context).cardColor, @@ -253,7 +273,7 @@ class _LinkPreviewState extends State const TextStyle(fontWeight: FontWeight.bold); return Text( title, - maxLines: 2, + maxLines: widget.maxTitleLines, overflow: TextOverflow.ellipsis, style: style, ); @@ -261,7 +281,7 @@ class _LinkPreviewState extends State Widget _descriptionWidget(String description) => Text( description, - maxLines: 3, + maxLines: widget.maxDescriptionLines, overflow: TextOverflow.ellipsis, style: widget.titleTextStyle, ); From 415330c041ff5d036ac9c7bbc794ea608bcd01cc Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Sat, 24 May 2025 08:37:00 +0200 Subject: [PATCH 08/32] Better image image size management --- .../lib/src/widgets/link_preview.dart | 47 +++++++++---------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/packages/flyer_chat_link_preview/lib/src/widgets/link_preview.dart b/packages/flyer_chat_link_preview/lib/src/widgets/link_preview.dart index 2d16a46b5..018e533f9 100644 --- a/packages/flyer_chat_link_preview/lib/src/widgets/link_preview.dart +++ b/packages/flyer_chat_link_preview/lib/src/widgets/link_preview.dart @@ -204,20 +204,26 @@ class _LinkPreviewState extends State children: [ if (_shouldShowTitle()) Flexible( + flex: 1, fit: FlexFit.loose, child: _titleWidget(_linkPreviewData!.title!), ), if (_shouldShowDescription()) Flexible( + flex: 1, fit: FlexFit.loose, child: _descriptionWidget( widget.linkPreviewData!.description!, ), ), if (imagePosition == LinkPreviewImagePosition.bottom) - _imageWidget( - imageUrl: _linkPreviewData!.image!.url, - linkUrl: _linkPreviewData!.link, + Flexible( + flex: 2, + fit: FlexFit.loose, + child: _imageWidget( + imageUrl: _linkPreviewData!.image!.url, + linkUrl: _linkPreviewData!.link, + ), ), ], ), @@ -228,12 +234,15 @@ class _LinkPreviewState extends State flex: 1, child: Column( mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ - _imageWidget( - imageUrl: _linkPreviewData!.image!.url, - linkUrl: _linkPreviewData!.link, + Flexible( + fit: FlexFit.loose, + child: _imageWidget( + imageUrl: _linkPreviewData!.image!.url, + linkUrl: _linkPreviewData!.link, + ), ), ], ), @@ -289,24 +298,12 @@ class _LinkPreviewState extends State Widget _imageWidget({required String imageUrl, required String linkUrl}) => GestureDetector( onTap: widget.openOnPreviewImageTap ? () => _onOpen(linkUrl) : null, - child: Center( - child: LayoutBuilder( - builder: (context, constraints) { - return ClipRRect( - borderRadius: BorderRadius.circular(widget.borderRadius), - child: Container( - constraints: BoxConstraints( - maxHeight: constraints.maxWidth, - maxWidth: constraints.maxWidth, - ), - child: - widget.imageBuilder != null - ? widget.imageBuilder!(imageUrl) - : Image.network(imageUrl, fit: BoxFit.contain), - ), - ); - }, - ), + child: ClipRRect( + borderRadius: BorderRadius.circular(widget.borderRadius), + child: + widget.imageBuilder != null + ? widget.imageBuilder!(imageUrl) + : Image.network(imageUrl, fit: BoxFit.contain), ), ); From 0810e9ab50cd30dcf96f90564e28e7b9f5b1949f Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Sat, 24 May 2025 08:37:47 +0200 Subject: [PATCH 09/32] Ensure bubble sizes account for the timeAndStatus widget size --- .../lib/src/simple_text_message.dart | 12 ++++-------- .../lib/src/flyer_chat_text_message.dart | 14 ++++---------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/packages/flutter_chat_ui/lib/src/simple_text_message.dart b/packages/flutter_chat_ui/lib/src/simple_text_message.dart index 1495c4ae2..ed9bff999 100644 --- a/packages/flutter_chat_ui/lib/src/simple_text_message.dart +++ b/packages/flutter_chat_ui/lib/src/simple_text_message.dart @@ -207,8 +207,10 @@ class SimpleTextMessage extends StatelessWidget { : textContent, if (effectiveLinkPreviewPosition == LinkPreviewPosition.bottom) linkPreviewWidget!, - if (timeAndStatusPosition == TimeAndStatusPosition.end) - SizedBox(height: textStyle?.lineHeight ?? 0), + if (timeAndStatusPosition != TimeAndStatusPosition.inline) + // Ensure the width is not smaller than the timeAndStatus widget + // Ensure the height accounts for it's height + Opacity(opacity: 0, child: timeAndStatus), ], ), if (timeAndStatusPosition != TimeAndStatusPosition.inline) @@ -250,12 +252,6 @@ class SimpleTextMessage extends StatelessWidget { } } -/// Internal extension for calculating the visual line height of a TextStyle. -extension on TextStyle { - /// Calculates the line height based on the style's `height` and `fontSize`. - double get lineHeight => (height ?? 1) * (fontSize ?? 0); -} - /// A widget to display the message timestamp and status indicator. class TimeAndStatus extends StatelessWidget { /// The time the message was created. diff --git a/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart b/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart index 200962126..7da94ac4d 100644 --- a/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart +++ b/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart @@ -235,10 +235,10 @@ class FlyerChatTextMessage extends StatelessWidget { : textContent, if (effectiveLinkPreviewPosition == LinkPreviewPosition.bottom) linkPreviewWidget!, - if (timeAndStatusPosition == TimeAndStatusPosition.end) - SizedBox(height: paragraphStyle?.lineHeight ?? 0), - if (timeAndStatusPosition == TimeAndStatusPosition.start) - timeAndStatus, + if (timeAndStatusPosition != TimeAndStatusPosition.inline) + // Ensure the width is not smaller than the timeAndStatus widget + // Ensure the height accounts for it's height + Opacity(opacity: 0, child: timeAndStatus), ], ), if (timeAndStatusPosition != TimeAndStatusPosition.inline) @@ -280,12 +280,6 @@ class FlyerChatTextMessage extends StatelessWidget { } } -/// Internal extension for calculating the visual line height of a TextStyle. -extension on TextStyle { - /// Calculates the line height based on the style's `height` and `fontSize`. - double get lineHeight => (height ?? 1) * (fontSize ?? 0); -} - /// A widget to display the message timestamp and status indicator. class TimeAndStatus extends StatelessWidget { /// The time the message was created. From 2293534ea5358f59f6c6e5eed75d704dd44fe616 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Sat, 24 May 2025 08:51:21 +0200 Subject: [PATCH 10/32] Fix forgotten extra linkPreview widget on top --- .../lib/src/flyer_chat_text_message.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart b/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart index 7da94ac4d..3e5686a16 100644 --- a/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart +++ b/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart @@ -165,9 +165,6 @@ class FlyerChatTextMessage extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (linkPreviewWidget != null && - linkPreviewPosition == LinkPreviewPosition.top) - linkPreviewWidget, Container( padding: _isOnlyEmoji From 12e61743002817261c1a6ccdb5e847cb300026cb Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Sat, 24 May 2025 09:06:16 +0200 Subject: [PATCH 11/32] Update callback to include if sentByMe and update example --- examples/flyer_chat/lib/link_preview.dart | 12 ++++++++---- .../flutter_chat_core/lib/src/models/builders.dart | 3 ++- .../flutter_chat_ui/lib/src/simple_text_message.dart | 1 + .../lib/src/flyer_chat_text_message.dart | 1 + 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/examples/flyer_chat/lib/link_preview.dart b/examples/flyer_chat/lib/link_preview.dart index 865ee1e24..16ee06077 100644 --- a/examples/flyer_chat/lib/link_preview.dart +++ b/examples/flyer_chat/lib/link_preview.dart @@ -49,7 +49,7 @@ class LinkPreviewExampleState extends State { ), Message.text( id: 'complete', - authorId: _currentUser.id, + authorId: 'someone', text: 'https://flyer.chat/ Already fetched', createdAt: DateTime.now().toUtc().subtract(const Duration(hours: 3)), linkPreviewData: LinkPreviewData( @@ -154,7 +154,7 @@ class LinkPreviewExampleState extends State { body: Chat( backgroundColor: Colors.transparent, builders: Builders( - linkPreviewBuilder: (context, message) { + linkPreviewBuilder: (context, message, isSentByMe) { final isLinkPreviewDataFetched = message.metadata?.containsKey(metadataLinkPreviewFetchedKey) ?? false; @@ -174,8 +174,12 @@ class LinkPreviewExampleState extends State { linkPreviewData: message.linkPreviewData, hideDescription: message.id == 'display-only-image', hideTitle: message.id == 'display-only-image', - backgroundColor: Colors.white.withAlpha(180), - sideBorderColor: Colors.white, + backgroundColor: + isSentByMe + ? Colors.white.withAlpha(100) + : chatTheme.colors.primary.withAlpha(100), + sideBorderColor: + isSentByMe ? Colors.white : chatTheme.colors.primary, onLinkPreviewDataFetched: (linkPreviewData) { _chatController.updateMessage( message, diff --git a/packages/flutter_chat_core/lib/src/models/builders.dart b/packages/flutter_chat_core/lib/src/models/builders.dart index 80caf6a90..a490a028d 100644 --- a/packages/flutter_chat_core/lib/src/models/builders.dart +++ b/packages/flutter_chat_core/lib/src/models/builders.dart @@ -131,7 +131,8 @@ typedef LoadMoreBuilder = Widget Function(BuildContext); typedef EmptyChatListBuilder = Widget Function(BuildContext); /// Signature for building the link preview widget. -typedef LinkPreviewBuilder = Widget? Function(BuildContext, TextMessage); +typedef LinkPreviewBuilder = + Widget? Function(BuildContext, TextMessage, bool isSendByMe); /// A collection of builder functions used to customize the UI components /// of the chat interface. diff --git a/packages/flutter_chat_ui/lib/src/simple_text_message.dart b/packages/flutter_chat_ui/lib/src/simple_text_message.dart index ed9bff999..bb2c39a64 100644 --- a/packages/flutter_chat_ui/lib/src/simple_text_message.dart +++ b/packages/flutter_chat_ui/lib/src/simple_text_message.dart @@ -131,6 +131,7 @@ class SimpleTextMessage extends StatelessWidget { ? context.read().linkPreviewBuilder?.call( context, message, + isSentByMe, ) : null; diff --git a/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart b/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart index 3e5686a16..75118c2eb 100644 --- a/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart +++ b/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart @@ -153,6 +153,7 @@ class FlyerChatTextMessage extends StatelessWidget { ? context.read().linkPreviewBuilder?.call( context, message, + isSentByMe, ) : null; From 8610f9a4f75d669613c16214178ed53f403eb18e Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Fri, 6 Jun 2025 18:38:16 +0200 Subject: [PATCH 12/32] Fix crashes --- .../lib/src/widgets/link_preview.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/flyer_chat_link_preview/lib/src/widgets/link_preview.dart b/packages/flyer_chat_link_preview/lib/src/widgets/link_preview.dart index 018e533f9..a9e112420 100644 --- a/packages/flyer_chat_link_preview/lib/src/widgets/link_preview.dart +++ b/packages/flyer_chat_link_preview/lib/src/widgets/link_preview.dart @@ -213,10 +213,11 @@ class _LinkPreviewState extends State flex: 1, fit: FlexFit.loose, child: _descriptionWidget( - widget.linkPreviewData!.description!, + _linkPreviewData!.description!, ), ), - if (imagePosition == LinkPreviewImagePosition.bottom) + if (_shouldShowImage() && + imagePosition == LinkPreviewImagePosition.bottom) Flexible( flex: 2, fit: FlexFit.loose, @@ -228,7 +229,8 @@ class _LinkPreviewState extends State ], ), ), - if (imagePosition == LinkPreviewImagePosition.side) ...[ + if (_shouldShowImage() && + imagePosition == LinkPreviewImagePosition.side) ...[ const SizedBox(width: 4), Flexible( flex: 1, From 9aae980b4227d29cd2082e82aa652851812aab3c Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Fri, 6 Jun 2025 18:38:33 +0200 Subject: [PATCH 13/32] Simplify onTap --- .../lib/src/widgets/link_preview.dart | 202 +++++++++--------- 1 file changed, 97 insertions(+), 105 deletions(-) diff --git a/packages/flyer_chat_link_preview/lib/src/widgets/link_preview.dart b/packages/flyer_chat_link_preview/lib/src/widgets/link_preview.dart index a9e112420..fa38f5861 100644 --- a/packages/flyer_chat_link_preview/lib/src/widgets/link_preview.dart +++ b/packages/flyer_chat_link_preview/lib/src/widgets/link_preview.dart @@ -25,9 +25,7 @@ class LinkPreview extends StatefulWidget { this.descriptionTextStyle, this.maxDescriptionLines = 3, this.imageBuilder, - this.onLinkPressed, - this.openOnPreviewImageTap = true, - this.openOnPreviewTitleTap = true, + this.onTap, this.hideImage = false, this.hideTitle = false, this.hideDescription = false, @@ -87,13 +85,7 @@ class LinkPreview extends StatefulWidget { final Widget Function(String)? imageBuilder; /// Custom link press handler. - final void Function(String)? onLinkPressed; - - /// Open the link when the link preview image is tapped. Defaults to true. - final bool openOnPreviewImageTap; - - /// Open the link when the link preview title/description is tapped. Defaults to true. - final bool openOnPreviewTitleTap; + final void Function(String)? onTap; /// Hides image data from the preview. final bool hideImage; @@ -176,103 +168,106 @@ class _LinkPreviewState extends State Widget _containerWidget({required LinkPreviewImagePosition imagePosition}) { final shouldAnimate = widget.enableAnimation == true; - final preview = Stack( - children: [ - Container( - constraints: BoxConstraints( - maxWidth: widget.maxWidth ?? double.infinity, - maxHeight: widget.maxHeight ?? double.infinity, - ), - margin: widget.outsidePadding, - decoration: BoxDecoration( - color: widget.backgroundColor ?? Theme.of(context).cardColor, - borderRadius: BorderRadius.circular(widget.borderRadius), - ), - child: Padding( - padding: widget.insidePadding, - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox(width: widget.borderRadius), - Flexible( - flex: 3, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (_shouldShowTitle()) - Flexible( - flex: 1, - fit: FlexFit.loose, - child: _titleWidget(_linkPreviewData!.title!), - ), - if (_shouldShowDescription()) - Flexible( - flex: 1, - fit: FlexFit.loose, - child: _descriptionWidget( - _linkPreviewData!.description!, - ), - ), - if (_shouldShowImage() && - imagePosition == LinkPreviewImagePosition.bottom) - Flexible( - flex: 2, - fit: FlexFit.loose, - child: _imageWidget( - imageUrl: _linkPreviewData!.image!.url, - linkUrl: _linkPreviewData!.link, - ), - ), - ], - ), - ), - if (_shouldShowImage() && - imagePosition == LinkPreviewImagePosition.side) ...[ - const SizedBox(width: 4), + final preview = GestureDetector( + onTap: () => _onTap(_linkPreviewData!.link), + child: Stack( + children: [ + Container( + constraints: BoxConstraints( + maxWidth: widget.maxWidth ?? double.infinity, + maxHeight: widget.maxHeight ?? double.infinity, + ), + margin: widget.outsidePadding, + decoration: BoxDecoration( + color: widget.backgroundColor ?? Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(widget.borderRadius), + ), + child: Padding( + padding: widget.insidePadding, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox(width: widget.borderRadius), Flexible( - flex: 1, + flex: 3, child: Column( - mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, children: [ - Flexible( - fit: FlexFit.loose, - child: _imageWidget( - imageUrl: _linkPreviewData!.image!.url, - linkUrl: _linkPreviewData!.link, + if (_shouldShowTitle()) + Flexible( + flex: 1, + fit: FlexFit.loose, + child: _titleWidget(_linkPreviewData!.title!), + ), + if (_shouldShowDescription()) + Flexible( + flex: 1, + fit: FlexFit.loose, + child: _descriptionWidget( + _linkPreviewData!.description!, + ), + ), + if (_shouldShowImage() && + imagePosition == LinkPreviewImagePosition.bottom) + Flexible( + flex: 2, + fit: FlexFit.loose, + child: _imageWidget( + imageUrl: _linkPreviewData!.image!.url, + linkUrl: _linkPreviewData!.link, + ), ), - ), ], ), ), + if (_shouldShowImage() && + imagePosition == LinkPreviewImagePosition.side) ...[ + const SizedBox(width: 4), + Flexible( + flex: 1, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + fit: FlexFit.loose, + child: _imageWidget( + imageUrl: _linkPreviewData!.image!.url, + linkUrl: _linkPreviewData!.link, + ), + ), + ], + ), + ), + ], ], - ], + ), ), ), - ), - Positioned( - left: 0, - top: 0, - bottom: 0, - child: Container( - margin: widget.outsidePadding, - width: widget.borderRadius, - decoration: BoxDecoration( - color: - widget.sideBorderColor ?? - Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.only( - topLeft: Radius.circular(widget.borderRadius), - bottomLeft: Radius.circular(widget.borderRadius), + Positioned( + left: 0, + top: 0, + bottom: 0, + child: Container( + margin: widget.outsidePadding, + width: widget.borderRadius, + decoration: BoxDecoration( + color: + widget.sideBorderColor ?? + Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(widget.borderRadius), + bottomLeft: Radius.circular(widget.borderRadius), + ), ), ), ), - ), - ], + ], + ), ); return shouldAnimate ? _animated(preview) : preview; @@ -298,15 +293,12 @@ class _LinkPreviewState extends State ); Widget _imageWidget({required String imageUrl, required String linkUrl}) => - GestureDetector( - onTap: widget.openOnPreviewImageTap ? () => _onOpen(linkUrl) : null, - child: ClipRRect( - borderRadius: BorderRadius.circular(widget.borderRadius), - child: - widget.imageBuilder != null - ? widget.imageBuilder!(imageUrl) - : Image.network(imageUrl, fit: BoxFit.contain), - ), + ClipRRect( + borderRadius: BorderRadius.circular(widget.borderRadius), + child: + widget.imageBuilder != null + ? widget.imageBuilder!(imageUrl) + : Image.network(imageUrl, fit: BoxFit.contain), ); Future _fetchData(String text) async { @@ -352,9 +344,9 @@ class _LinkPreviewState extends State bool _shouldShowDescription() => _linkPreviewData?.description != null && !widget.hideDescription; - Future _onOpen(String url) async { - if (widget.onLinkPressed != null) { - widget.onLinkPressed!(url); + Future _onTap(String url) async { + if (widget.onTap != null) { + widget.onTap!(url); } else { final uri = Uri.parse(url); if (await canLaunchUrl(uri)) { From 4169542358688c6d3fde2c8e5f89628797c5d977 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Sat, 7 Jun 2025 10:58:26 +0200 Subject: [PATCH 14/32] Lowercase text, iOS has a tendency to put HTTPS which does not work --- packages/flyer_chat_link_preview/lib/src/utils.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/flyer_chat_link_preview/lib/src/utils.dart b/packages/flyer_chat_link_preview/lib/src/utils.dart index 16ba04ba0..c92bcf919 100644 --- a/packages/flyer_chat_link_preview/lib/src/utils.dart +++ b/packages/flyer_chat_link_preview/lib/src/utils.dart @@ -177,6 +177,7 @@ Future getLinkPreviewData( ImagePreviewData? previewDataImage; try { + text = text.toLowerCase(); final emailRegexp = RegExp(regexEmail, caseSensitive: false); final textWithoutEmails = text.replaceAllMapped(emailRegexp, (match) => '').trim(); From 60a1fb6a9097bd7522aa368b1d19575e261a463e Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Tue, 10 Jun 2025 09:14:24 +0200 Subject: [PATCH 15/32] Revert previous commit --- packages/flyer_chat_link_preview/lib/src/utils.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/flyer_chat_link_preview/lib/src/utils.dart b/packages/flyer_chat_link_preview/lib/src/utils.dart index c92bcf919..16ba04ba0 100644 --- a/packages/flyer_chat_link_preview/lib/src/utils.dart +++ b/packages/flyer_chat_link_preview/lib/src/utils.dart @@ -177,7 +177,6 @@ Future getLinkPreviewData( ImagePreviewData? previewDataImage; try { - text = text.toLowerCase(); final emailRegexp = RegExp(regexEmail, caseSensitive: false); final textWithoutEmails = text.replaceAllMapped(emailRegexp, (match) => '').trim(); From 638094221daa8ac56dff37b98052225852d55398 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Tue, 10 Jun 2025 09:16:00 +0200 Subject: [PATCH 16/32] Fix: return the proxied url for image content --- packages/flyer_chat_link_preview/lib/src/utils.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/flyer_chat_link_preview/lib/src/utils.dart b/packages/flyer_chat_link_preview/lib/src/utils.dart index 16ba04ba0..26fd06127 100644 --- a/packages/flyer_chat_link_preview/lib/src/utils.dart +++ b/packages/flyer_chat_link_preview/lib/src/utils.dart @@ -205,9 +205,9 @@ Future getLinkPreviewData( if (imageRegexp.hasMatch(response.headers['content-type'] ?? '')) { final imageSize = await _getImageSizeFromBytes(response.bodyBytes); return LinkPreviewData( - link: url, + link: previewDataUrl, image: ImagePreviewData( - url: url, + url: previewDataUrl, height: imageSize.height, width: imageSize.width, ), From c98b3d3d49293442a23722abaed5b99439796b0b Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Tue, 10 Jun 2025 09:16:23 +0200 Subject: [PATCH 17/32] Fix crash when content is not utf8 --- .../lib/src/utils.dart | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/flyer_chat_link_preview/lib/src/utils.dart b/packages/flyer_chat_link_preview/lib/src/utils.dart index 26fd06127..a9f2c3b74 100644 --- a/packages/flyer_chat_link_preview/lib/src/utils.dart +++ b/packages/flyer_chat_link_preview/lib/src/utils.dart @@ -33,17 +33,6 @@ String? _getMetaContent(Document document, String propertyValue) { return element.attributes['content']?.trim(); } -bool _hasUTF8Charset(Document document) { - final emptyElement = Element.tag(null); - final meta = document.getElementsByTagName('meta'); - final element = meta.firstWhere( - (e) => e.attributes.containsKey('charset'), - orElse: () => emptyElement, - ); - if (element == emptyElement) return true; - return element.attributes['charset']!.toLowerCase() == 'utf-8'; -} - String? _getTitle(Document document) { final titleElements = document.getElementsByTagName('title'); if (titleElements.isNotEmpty) return titleElements.first.text; @@ -214,9 +203,22 @@ Future getLinkPreviewData( ); } - final document = parser.parse(utf8.decode(response.bodyBytes)); - if (!_hasUTF8Charset(document)) { - return LinkPreviewData(link: url); + Document document; + try { + Encoding encoding; + final contentType = response.headers['content-type']?.toLowerCase() ?? ''; + if (contentType.contains('charset=')) { + final charset = contentType.split('charset=')[1].split(';')[0].trim(); + encoding = Encoding.getByName(charset) ?? utf8; + debugPrint('encoding: $encoding'); + } else { + encoding = utf8; + } + + document = parser.parse(encoding.decode(response.bodyBytes)); + } catch (e) { + // Always return the url so it's displayed and clickable + return LinkPreviewData(link: previewDataUrl, title: previewDataTitle); } final title = _getTitle(document); From cb70fd289382911952365b4f0279180e69899170 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Tue, 10 Jun 2025 09:17:03 +0200 Subject: [PATCH 18/32] Add try-catch to always return something if possible even when images 404 --- .../lib/src/utils.dart | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/packages/flyer_chat_link_preview/lib/src/utils.dart b/packages/flyer_chat_link_preview/lib/src/utils.dart index a9f2c3b74..8618b3e9e 100644 --- a/packages/flyer_chat_link_preview/lib/src/utils.dart +++ b/packages/flyer_chat_link_preview/lib/src/utils.dart @@ -164,6 +164,8 @@ Future getLinkPreviewData( String? previewDataTitle; String? previewDataUrl; ImagePreviewData? previewDataImage; + Size imageSize; + String previewDataImageUrl; try { final emailRegexp = RegExp(regexEmail, caseSensitive: false); @@ -231,24 +233,23 @@ Future getLinkPreviewData( previewDataDescription = description.trim(); } - final imageUrls = _getImageUrls(document, url); - - Size imageSize; - String previewDataImageUrl; + try { + final imageUrls = _getImageUrls(document, url); - if (imageUrls.isNotEmpty) { - previewDataImageUrl = - imageUrls.length == 1 - ? _calculateUrl(imageUrls[0], proxy) - : await _getBiggestImageUrl(imageUrls, proxy); + if (imageUrls.isNotEmpty) { + previewDataImageUrl = + imageUrls.length == 1 + ? _calculateUrl(imageUrls[0], proxy) + : await _getBiggestImageUrl(imageUrls, proxy); - imageSize = await _getImageSize(previewDataImageUrl); - previewDataImage = ImagePreviewData( - url: previewDataImageUrl, - width: imageSize.width, - height: imageSize.height, - ); - } + imageSize = await _getImageSize(previewDataImageUrl); + previewDataImage = ImagePreviewData( + url: previewDataImageUrl, + width: imageSize.width, + height: imageSize.height, + ); + } + } catch (_) {} return LinkPreviewData( link: previewDataUrl, title: previewDataTitle, From 1515a8bce0d9002b5dc697e2c210402c4c8a5315 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Tue, 10 Jun 2025 09:54:21 +0200 Subject: [PATCH 19/32] Better handle redirects for relative images paths --- .../lib/src/utils.dart | 46 +++++++++++++++++-- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/flyer_chat_link_preview/lib/src/utils.dart b/packages/flyer_chat_link_preview/lib/src/utils.dart index 8618b3e9e..43476bfea 100644 --- a/packages/flyer_chat_link_preview/lib/src/utils.dart +++ b/packages/flyer_chat_link_preview/lib/src/utils.dart @@ -7,7 +7,7 @@ import 'package:flutter_chat_core/flutter_chat_core.dart' show LinkPreviewData, ImagePreviewData; import 'package:html/dom.dart' show Document, Element; import 'package:html/parser.dart' as parser show parse; -import 'package:http/http.dart' as http show get; +import 'package:http/http.dart' as http show Request, Client, Response; import 'types.dart'; @@ -153,6 +153,37 @@ Future _getBiggestImageUrl( return currentUrl; } +Future _getRedirectedResponse( + Uri uri, { + String? userAgent, + int maxRedirects = 5, + Duration timeout = const Duration(seconds: 5), + http.Client? client, +}) async { + final httpClient = client ?? http.Client(); + var redirectCount = 0; + + while (redirectCount < maxRedirects) { + final request = + http.Request('GET', uri) + ..followRedirects = false + ..headers.addAll({if (userAgent != null) 'User-Agent': userAgent}); + + final streamedResponse = await httpClient.send(request).timeout(timeout); + + if (streamedResponse.isRedirect && + streamedResponse.headers.containsKey('location')) { + uri = uri.resolve(streamedResponse.headers['location']!); + redirectCount++; + continue; + } + + return http.Response.fromStream(streamedResponse); + } + + return null; +} + /// Parses provided text and returns [PreviewData] for the first found link. Future getLinkPreviewData( String text, { @@ -187,9 +218,16 @@ Future getLinkPreviewData( } previewDataUrl = _calculateUrl(url, proxy); final uri = Uri.parse(previewDataUrl); - final response = await http - .get(uri, headers: {'User-Agent': userAgent ?? 'WhatsApp/2'}) - .timeout(requestTimeout ?? const Duration(seconds: 5)); + final response = await _getRedirectedResponse( + uri, + timeout: requestTimeout ?? const Duration(seconds: 5), + userAgent: userAgent ?? 'WhatsApp/2', + ); + + if (response == null || response.statusCode != 200) { + return null; + } + url = response.request?.url.toString() ?? url; final imageRegexp = RegExp(regexImageContentType); From 14729a146125d833fe2c5473fb833783c275dd67 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Wed, 11 Jun 2025 11:08:39 +0200 Subject: [PATCH 20/32] Remove commented code --- .../lib/src/flyer_chat_text_message.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart b/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart index 75118c2eb..a77d5369d 100644 --- a/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart +++ b/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart @@ -182,9 +182,6 @@ class FlyerChatTextMessage extends StatelessWidget { linkPreviewWidget: linkPreviewWidget, ), ), - // if (linkPreviewWidget != null && - // linkPreviewPosition == LinkPreviewPosition.bottom) - // linkPreviewWidget, ], ), ), From e15183166b099884c94ccf29bfada6e32c7968d7 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Wed, 11 Jun 2025 12:34:50 +0200 Subject: [PATCH 21/32] Fix preview not display if no TimeAndStatus --- packages/flutter_chat_ui/lib/src/simple_text_message.dart | 7 ++----- .../lib/src/flyer_chat_text_message.dart | 7 ++----- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/flutter_chat_ui/lib/src/simple_text_message.dart b/packages/flutter_chat_ui/lib/src/simple_text_message.dart index bb2c39a64..2dfd2b0ce 100644 --- a/packages/flutter_chat_ui/lib/src/simple_text_message.dart +++ b/packages/flutter_chat_ui/lib/src/simple_text_message.dart @@ -173,10 +173,6 @@ class SimpleTextMessage extends StatelessWidget { TextStyle? textStyle, Widget? linkPreviewWidget, }) { - if (timeAndStatus == null) { - return textContent; - } - final textDirection = Directionality.of(context); final effectiveLinkPreviewPosition = linkPreviewWidget != null @@ -214,7 +210,8 @@ class SimpleTextMessage extends StatelessWidget { Opacity(opacity: 0, child: timeAndStatus), ], ), - if (timeAndStatusPosition != TimeAndStatusPosition.inline) + if (timeAndStatusPosition != TimeAndStatusPosition.inline && + timeAndStatus != null) Positioned.directional( textDirection: textDirection, end: timeAndStatusPosition == TimeAndStatusPosition.end ? 0 : null, diff --git a/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart b/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart index a77d5369d..16f353607 100644 --- a/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart +++ b/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart @@ -195,10 +195,6 @@ class FlyerChatTextMessage extends StatelessWidget { TextStyle? paragraphStyle, Widget? linkPreviewWidget, }) { - if (timeAndStatus == null) { - return textContent; - } - final textDirection = Directionality.of(context); final effectiveLinkPreviewPosition = linkPreviewWidget != null @@ -236,7 +232,8 @@ class FlyerChatTextMessage extends StatelessWidget { Opacity(opacity: 0, child: timeAndStatus), ], ), - if (timeAndStatusPosition != TimeAndStatusPosition.inline) + if (timeAndStatusPosition != TimeAndStatusPosition.inline && + timeAndStatus != null) Positioned.directional( textDirection: textDirection, end: timeAndStatusPosition == TimeAndStatusPosition.end ? 0 : null, From 03643189067322054999abbc39179aa21a2cb097 Mon Sep 17 00:00:00 2001 From: Alex Demchenko Date: Wed, 2 Jul 2025 22:48:08 +0200 Subject: [PATCH 22/32] rebase and fix dependencies --- examples/flyer_chat/ios/Podfile.lock | 6 ++++++ examples/flyer_chat/lib/main.dart | 2 +- .../flyer_chat/linux/flutter/generated_plugin_registrant.cc | 4 ++++ examples/flyer_chat/linux/flutter/generated_plugins.cmake | 1 + .../macos/Flutter/GeneratedPluginRegistrant.swift | 2 ++ examples/flyer_chat/macos/Podfile.lock | 6 ++++++ .../windows/flutter/generated_plugin_registrant.cc | 3 +++ examples/flyer_chat/windows/flutter/generated_plugins.cmake | 1 + 8 files changed, 24 insertions(+), 1 deletion(-) diff --git a/examples/flyer_chat/ios/Podfile.lock b/examples/flyer_chat/ios/Podfile.lock index e0b8e75a2..81472501a 100644 --- a/examples/flyer_chat/ios/Podfile.lock +++ b/examples/flyer_chat/ios/Podfile.lock @@ -47,6 +47,8 @@ PODS: - SDWebImage/Core (= 5.21.0) - SDWebImage/Core (5.21.0) - SwiftyGif (5.4.5) + - url_launcher_ios (0.0.1): + - Flutter DEPENDENCIES: - file_picker (from `.symlinks/plugins/file_picker/ios`) @@ -55,6 +57,7 @@ DEPENDENCIES: - integration_test (from `.symlinks/plugins/integration_test/ios`) - isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) SPEC REPOS: trunk: @@ -76,6 +79,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/isar_flutter_libs/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c @@ -88,6 +93,7 @@ SPEC CHECKSUMS: path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d PODFILE CHECKSUM: 4305caec6b40dde0ae97be1573c53de1882a07e5 diff --git a/examples/flyer_chat/lib/main.dart b/examples/flyer_chat/lib/main.dart index 0c15924f1..3fe0c597a 100644 --- a/examples/flyer_chat/lib/main.dart +++ b/examples/flyer_chat/lib/main.dart @@ -11,8 +11,8 @@ import 'api_get_chat_id.dart'; import 'api_get_initial_messages.dart'; import 'basic.dart'; import 'gemini.dart'; -import 'local.dart'; import 'link_preview.dart'; +import 'local.dart'; import 'pagination.dart'; void main() async { diff --git a/examples/flyer_chat/linux/flutter/generated_plugin_registrant.cc b/examples/flyer_chat/linux/flutter/generated_plugin_registrant.cc index 02bc14a77..31124ea32 100644 --- a/examples/flyer_chat/linux/flutter/generated_plugin_registrant.cc +++ b/examples/flyer_chat/linux/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = @@ -16,4 +17,7 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) isar_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "IsarFlutterLibsPlugin"); isar_flutter_libs_plugin_register_with_registrar(isar_flutter_libs_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/examples/flyer_chat/linux/flutter/generated_plugins.cmake b/examples/flyer_chat/linux/flutter/generated_plugins.cmake index 00bef1846..00d762d49 100644 --- a/examples/flyer_chat/linux/flutter/generated_plugins.cmake +++ b/examples/flyer_chat/linux/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux isar_flutter_libs + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/examples/flyer_chat/macos/Flutter/GeneratedPluginRegistrant.swift b/examples/flyer_chat/macos/Flutter/GeneratedPluginRegistrant.swift index 5843962bb..d48ecf7cf 100644 --- a/examples/flyer_chat/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/examples/flyer_chat/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,10 +9,12 @@ import file_picker import file_selector_macos import isar_flutter_libs import path_provider_foundation +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) IsarFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "IsarFlutterLibsPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/examples/flyer_chat/macos/Podfile.lock b/examples/flyer_chat/macos/Podfile.lock index bfecb1f1a..9da77816f 100644 --- a/examples/flyer_chat/macos/Podfile.lock +++ b/examples/flyer_chat/macos/Podfile.lock @@ -9,6 +9,8 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS DEPENDENCIES: - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) @@ -16,6 +18,7 @@ DEPENDENCIES: - FlutterMacOS (from `Flutter/ephemeral`) - isar_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/isar_flutter_libs/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) EXTERNAL SOURCES: file_picker: @@ -28,6 +31,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/isar_flutter_libs/macos path_provider_foundation: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos SPEC CHECKSUMS: file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a @@ -35,6 +40,7 @@ SPEC CHECKSUMS: FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 isar_flutter_libs: a65381780401f81ad6bf3f2e7cd0de5698fb98c4 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 PODFILE CHECKSUM: 7eb978b976557c8c1cd717d8185ec483fd090a82 diff --git a/examples/flyer_chat/windows/flutter/generated_plugin_registrant.cc b/examples/flyer_chat/windows/flutter/generated_plugin_registrant.cc index df32f874f..f380d6e46 100644 --- a/examples/flyer_chat/windows/flutter/generated_plugin_registrant.cc +++ b/examples/flyer_chat/windows/flutter/generated_plugin_registrant.cc @@ -8,10 +8,13 @@ #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); IsarFlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("IsarFlutterLibsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/examples/flyer_chat/windows/flutter/generated_plugins.cmake b/examples/flyer_chat/windows/flutter/generated_plugins.cmake index 52869ab32..383a7fda4 100644 --- a/examples/flyer_chat/windows/flutter/generated_plugins.cmake +++ b/examples/flyer_chat/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows isar_flutter_libs + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST From 126d2867e31c24bad25a556d12aeae727e27cc51 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Wed, 11 Jun 2025 11:09:42 +0200 Subject: [PATCH 23/32] Implement in FlyerChatTextMessage --- .../lib/src/flyer_chat_text_message.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart b/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart index 16f353607..417d9570b 100644 --- a/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart +++ b/packages/flyer_chat_text_message/lib/src/flyer_chat_text_message.dart @@ -78,6 +78,9 @@ class FlyerChatTextMessage extends StatelessWidget { /// A [LinkPreviewBuilder] must be provided for the preview to be displayed. final LinkPreviewPosition linkPreviewPosition; + /// The widgets to display before the message. + final List? topWidgets; + /// Creates a widget to display a text message. const FlyerChatTextMessage({ super.key, @@ -100,6 +103,7 @@ class FlyerChatTextMessage extends StatelessWidget { this.timeAndStatusPositionInlineInsets = const EdgeInsets.only(bottom: 2), this.onLinkTap, this.linkPreviewPosition = LinkPreviewPosition.bottom, + this.topWidgets, }); bool get _isOnlyEmoji => message.metadata?['isOnlyEmoji'] == true; @@ -207,6 +211,7 @@ class FlyerChatTextMessage extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (topWidgets != null) ...topWidgets!, if (effectiveLinkPreviewPosition == LinkPreviewPosition.top) linkPreviewWidget!, timeAndStatusPosition == TimeAndStatusPosition.inline From 63385008e42c79e59b151e53c65290c4dc1f636e Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Fri, 4 Jul 2025 10:28:05 +0200 Subject: [PATCH 24/32] Implement in FlyerChatImageMessage --- .../lib/src/flyer_chat_image_message.dart | 257 +++++++++++------- 1 file changed, 154 insertions(+), 103 deletions(-) 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 317cf7c17..47b455b80 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 @@ -11,6 +11,17 @@ import 'package:thumbhash/thumbhash.dart' import 'get_image_dimensions.dart'; +/// Theme values for [FlyerChatTextMessage]. +typedef _LocalTheme = + ({ + TextStyle labelSmall, + Color onSurface, + Color primary, + BorderRadiusGeometry shape, + Color surfaceContainer, + Color surfaceContainerLow, + }); + /// A widget that displays an image message. /// /// Uses [CachedNetworkImage] for efficient loading and caching. @@ -77,6 +88,15 @@ class FlyerChatImageMessage extends StatefulWidget { /// Error builder for the image widget. final ImageErrorWidgetBuilder? errorBuilder; + /// Background color for messages sent by the current user. + final Color? sentBackgroundColor; + + /// Background color for messages received from other users. + final Color? receivedBackgroundColor; + + /// The widgets to display before the message. + final List? topWidgets; + /// Creates a widget to display an image message. const FlyerChatImageMessage({ super.key, @@ -98,6 +118,9 @@ class FlyerChatImageMessage extends StatefulWidget { this.showStatus = true, this.timeAndStatusPosition = TimeAndStatusPosition.end, this.errorBuilder, + this.sentBackgroundColor, + this.receivedBackgroundColor, + this.topWidgets, }); @override @@ -185,12 +208,21 @@ class _FlyerChatImageMessageState extends State super.dispose(); } + Color? _resolveBackgroundColor(bool isSentByMe, _LocalTheme theme) { + if (isSentByMe) { + return widget.sentBackgroundColor ?? theme.primary; + } + return widget.receivedBackgroundColor ?? theme.surfaceContainer; + } + @override Widget build(BuildContext context) { final theme = context.select( (ChatTheme t) => ( labelSmall: t.typography.labelSmall, onSurface: t.colors.onSurface, + primary: t.colors.primary, + surfaceContainer: t.colors.surfaceContainer, shape: t.shape, surfaceContainerLow: t.colors.surfaceContainerLow, ), @@ -216,113 +248,132 @@ class _FlyerChatImageMessageState extends State borderRadius: widget.borderRadius ?? theme.shape, child: Container( constraints: widget.constraints, - child: AspectRatio( - aspectRatio: _aspectRatio, - child: Stack( - fit: StackFit.expand, - children: [ - _placeholderProvider != null - ? Image(image: _placeholderProvider!, fit: BoxFit.fill) - : Container( - color: widget.placeholderColor ?? theme.surfaceContainerLow, - ), - Image( - image: _imageProvider, - fit: BoxFit.fill, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) { - return child; - } - - return Container( - color: - widget.loadingOverlayColor ?? - theme.surfaceContainerLow.withValues(alpha: 0.5), - child: Center( - child: CircularProgressIndicator( - color: - widget.loadingIndicatorColor ?? - theme.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, - ); - }, - errorBuilder: widget.errorBuilder, - ), - 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.surfaceContainerLow.withValues(alpha: 0.5), - child: Center( - child: CircularProgressIndicator( + color: _resolveBackgroundColor(isSentByMe, theme), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.topWidgets != null) ...widget.topWidgets!, + Flexible( + child: AspectRatio( + aspectRatio: _aspectRatio, + child: Stack( + fit: StackFit.expand, + children: [ + _placeholderProvider != null + ? Image(image: _placeholderProvider!, fit: BoxFit.fill) + : Container( color: - widget.uploadIndicatorColor ?? - theme.onSurface.withValues(alpha: 0.8), - strokeCap: StrokeCap.round, - value: snapshot.data, + widget.placeholderColor ?? + theme.surfaceContainerLow, ), + Image( + image: _imageProvider, + fit: BoxFit.fill, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) { + return child; + } + + return Container( + color: + widget.loadingOverlayColor ?? + theme.surfaceContainerLow.withValues(alpha: 0.5), + child: Center( + child: CircularProgressIndicator( + color: + widget.loadingIndicatorColor ?? + theme.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, + ); + }, + errorBuilder: widget.errorBuilder, + ), + 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.surfaceContainerLow.withValues( + alpha: 0.5, + ), + child: Center( + child: CircularProgressIndicator( + color: + widget.uploadIndicatorColor ?? + theme.onSurface.withValues(alpha: 0.8), + strokeCap: StrokeCap.round, + value: snapshot.data, + ), + ), + ); + }, ), - ); - }, - ), - 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, + 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, + ), + ], ), - ], - ), + ), + ), + ], ), ), ); From 3d2a3c5228acddf90a3e75f7253eeac2a036c27a Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Fri, 4 Jul 2025 10:28:18 +0200 Subject: [PATCH 25/32] Demo --- examples/flyer_chat/lib/main.dart | 12 + examples/flyer_chat/lib/topwidgets_demo.dart | 302 +++++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 examples/flyer_chat/lib/topwidgets_demo.dart diff --git a/examples/flyer_chat/lib/main.dart b/examples/flyer_chat/lib/main.dart index 3fe0c597a..200182f8d 100644 --- a/examples/flyer_chat/lib/main.dart +++ b/examples/flyer_chat/lib/main.dart @@ -14,6 +14,7 @@ import 'gemini.dart'; import 'link_preview.dart'; import 'local.dart'; import 'pagination.dart'; +import 'topwidgets_demo.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -295,6 +296,17 @@ class _FlyerChatHomePageState extends State { }, child: const Text('link preview'), ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => TopWidgetInBubble(dio: _dio), + ), + ); + }, + child: const Text('Top widgets in bubble'), + ), ], ), ), diff --git a/examples/flyer_chat/lib/topwidgets_demo.dart b/examples/flyer_chat/lib/topwidgets_demo.dart new file mode 100644 index 000000000..200c54ba1 --- /dev/null +++ b/examples/flyer_chat/lib/topwidgets_demo.dart @@ -0,0 +1,302 @@ +import 'dart:math'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:flutter_chat_ui/flutter_chat_ui.dart'; +import 'package:flyer_chat_file_message/flyer_chat_file_message.dart'; +import 'package:flyer_chat_image_message/flyer_chat_image_message.dart'; +import 'package:flyer_chat_system_message/flyer_chat_system_message.dart'; +import 'package:flyer_chat_text_message/flyer_chat_text_message.dart'; +import 'package:uuid/uuid.dart'; + +import 'create_message.dart'; +import 'widgets/composer_action_bar.dart'; + +class TopWidgetInBubble extends StatefulWidget { + final Dio dio; + + const TopWidgetInBubble({super.key, required this.dio}); + + @override + TopWidgetInBubbleState createState() => TopWidgetInBubbleState(); +} + +class TopWidgetInBubbleState extends State { + final _chatController = InMemoryChatController(); + final _uuid = const Uuid(); + + final _currentUser = const User( + id: 'me', + imageSource: 'https://picsum.photos/id/65/200/200', + name: 'Jane Doe', + ); + final _recipient = const User( + id: 'recipient', + imageSource: 'https://picsum.photos/id/265/200/200', + name: 'John Doe', + ); + final _systemUser = const User(id: 'system'); + + @override + void dispose() { + _chatController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar(title: const Text('Local')), + body: Chat( + backgroundColor: Colors.transparent, + builders: Builders( + chatAnimatedListBuilder: (context, itemBuilder) { + return ChatAnimatedList( + itemBuilder: itemBuilder, + insertAnimationDurationResolver: (message) { + if (message is SystemMessage) return Duration.zero; + return null; + }, + ); + }, + customMessageBuilder: + ( + context, + message, + index, { + required bool isSentByMe, + MessageGroupStatus? groupStatus, + }) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + decoration: BoxDecoration( + color: theme.brightness == Brightness.dark + ? ChatColors.dark().surfaceContainer + : ChatColors.light().surfaceContainer, + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + child: IsTypingIndicator(), + ), + imageMessageBuilder: + ( + context, + message, + index, { + required bool isSentByMe, + MessageGroupStatus? groupStatus, + }) => FlyerChatImageMessage( + message: message, + index: index, + topWidgets: [_buildUsername(message, isSentByMe, groupStatus)], + ), + systemMessageBuilder: + ( + context, + message, + index, { + required bool isSentByMe, + MessageGroupStatus? groupStatus, + }) => FlyerChatSystemMessage(message: message, index: index), + composerBuilder: (context) => Composer( + topWidget: ComposerActionBar( + buttons: [ + ComposerActionButton( + icon: Icons.shuffle, + title: 'Send random', + onPressed: () => _addItem(null), + ), + ComposerActionButton( + icon: Icons.delete_sweep, + title: 'Clear all', + onPressed: () => _chatController.setMessages([]), + destructive: true, + ), + ], + ), + ), + textMessageBuilder: + ( + context, + message, + index, { + required bool isSentByMe, + MessageGroupStatus? groupStatus, + }) => FlyerChatTextMessage( + message: message, + index: index, + topWidgets: [_buildUsername(message, isSentByMe, groupStatus)], + ), + fileMessageBuilder: + ( + context, + message, + index, { + required bool isSentByMe, + MessageGroupStatus? groupStatus, + }) => FlyerChatFileMessage(message: message, index: index), + chatMessageBuilder: + ( + context, + message, + index, + animation, + child, { + bool? isRemoved, + required bool isSentByMe, + MessageGroupStatus? groupStatus, + }) { + final isSystemMessage = message.authorId == 'system'; + final isLastInGroup = groupStatus?.isLast ?? true; + final shouldShowAvatar = + !isSystemMessage && isLastInGroup && isRemoved != true; + final isCurrentUser = message.authorId == _currentUser.id; + + Widget? avatar; + if (shouldShowAvatar) { + avatar = Padding( + padding: EdgeInsets.only( + left: isCurrentUser ? 8 : 0, + right: isCurrentUser ? 0 : 8, + ), + child: Avatar(userId: message.authorId), + ); + } else if (!isSystemMessage) { + avatar = const SizedBox(width: 40); + } + + return ChatMessage( + message: message, + index: index, + animation: animation, + isRemoved: isRemoved, + groupStatus: groupStatus, + leadingWidget: !isCurrentUser + ? avatar + : isSystemMessage + ? null + : const SizedBox(width: 40), + trailingWidget: isCurrentUser + ? avatar + : isSystemMessage + ? null + : const SizedBox(width: 40), + receivedMessageScaleAnimationAlignment: + (message is SystemMessage) + ? Alignment.center + : Alignment.centerLeft, + receivedMessageAlignment: (message is SystemMessage) + ? AlignmentDirectional.center + : AlignmentDirectional.centerStart, + horizontalPadding: (message is SystemMessage) ? 0 : 8, + child: child, + ); + }, + ), + chatController: _chatController, + currentUserId: _currentUser.id, + decoration: BoxDecoration( + color: theme.brightness == Brightness.dark + ? ChatColors.dark().surface + : ChatColors.light().surface, + image: DecorationImage( + image: AssetImage('assets/pattern.png'), + repeat: ImageRepeat.repeat, + colorFilter: ColorFilter.mode( + theme.brightness == Brightness.dark + ? ChatColors.dark().surfaceContainerLow + : ChatColors.light().surfaceContainerLow, + BlendMode.srcIn, + ), + ), + ), + onMessageSend: _addItem, + resolveUser: (id) => Future.value(switch (id) { + 'me' => _currentUser, + 'recipient' => _recipient, + 'system' => _systemUser, + _ => null, + }), + theme: theme.brightness == Brightness.dark + ? ChatTheme.dark() + : ChatTheme.light(), + ), + ); + } + + void _addItem(String? text) async { + final randomUser = Random().nextInt(2) == 0 ? _currentUser : _recipient; + + final message = await createMessage( + randomUser.id, + widget.dio, + localOnly: true, + text: text, + ); + + if (mounted) { + if (_chatController.messages.isEmpty) { + final now = DateTime.now().toUtc(); + final formattedDate = DateFormat( + 'd MMMM yyyy, HH:mm', + ).format(now.toLocal()); + await _chatController.insertMessage( + SystemMessage( + id: _uuid.v4(), + authorId: _systemUser.id, + text: formattedDate, + createdAt: DateTime.now().toUtc().subtract( + const Duration(seconds: 1), + ), + ), + ); + } + await _chatController.insertMessage(message); + } + } + + bool _shouldDisplayUsername( + bool isSentByMe, + MessageGroupStatus? groupStatus, + ) { + if (isSentByMe) { + return false; + } + if (groupStatus?.isFirst ?? true) { + return true; + } + return false; + } + + Widget _buildUsername( + Message message, + bool isSentByMe, + MessageGroupStatus? groupStatus, + ) { + if (!_shouldDisplayUsername(isSentByMe, groupStatus)) { + return const SizedBox.shrink(); + } + // Resove somehow a user color, it's your own loading + final color = message.authorId == _currentUser.id + ? Colors.blue.shade400 + : Colors.red.shade400; + // Get the theme from your code or wathever fits + final textStyle = Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: color, fontWeight: FontWeight.bold); + // Adapt padding + EdgeInsets? padding; + if (message is ImageMessage) { + // Mimic FlyerChatText Default Padding + padding = EdgeInsets.only(top: 5, left: 8, right: 8); + } + return Padding( + padding: padding ?? EdgeInsets.zero, + child: Username(userId: message.authorId, style: textStyle), + ); + } +} From 9891f6004520dc8f3c1059c1995e4b8824a2fb53 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Fri, 4 Jul 2025 09:37:27 +0200 Subject: [PATCH 26/32] Add for SimpleTextMessage --- packages/flutter_chat_ui/lib/src/simple_text_message.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/flutter_chat_ui/lib/src/simple_text_message.dart b/packages/flutter_chat_ui/lib/src/simple_text_message.dart index 2dfd2b0ce..6c09328e2 100644 --- a/packages/flutter_chat_ui/lib/src/simple_text_message.dart +++ b/packages/flutter_chat_ui/lib/src/simple_text_message.dart @@ -66,6 +66,9 @@ class SimpleTextMessage extends StatelessWidget { /// A [LinkPreviewBuilder] must be provided for the preview to be displayed. final LinkPreviewPosition linkPreviewPosition; + /// The widgets to display before the message. + final List? topWidgets; + /// Creates a widget to display a simple text message. const SimpleTextMessage({ super.key, @@ -85,6 +88,7 @@ class SimpleTextMessage extends StatelessWidget { this.timeAndStatusPosition = TimeAndStatusPosition.end, this.timeAndStatusPositionInlineInsets = const EdgeInsets.only(bottom: 2), this.linkPreviewPosition = LinkPreviewPosition.bottom, + this.topWidgets, }); bool get _isOnlyEmoji => message.metadata?['isOnlyEmoji'] == true; @@ -185,6 +189,7 @@ class SimpleTextMessage extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (topWidgets != null) ...topWidgets!, if (effectiveLinkPreviewPosition == LinkPreviewPosition.top) linkPreviewWidget!, timeAndStatusPosition == TimeAndStatusPosition.inline From c9bce8581e196298fdb0c8ff48c302d2e4ab3e90 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Fri, 4 Jul 2025 10:07:04 +0200 Subject: [PATCH 27/32] Add support for fileMessages --- examples/flyer_chat/lib/create_message.dart | 33 +++++++--- examples/flyer_chat/lib/topwidgets_demo.dart | 7 ++- .../lib/src/flyer_chat_file_message.dart | 62 +++++++++---------- 3 files changed, 59 insertions(+), 43 deletions(-) diff --git a/examples/flyer_chat/lib/create_message.dart b/examples/flyer_chat/lib/create_message.dart index 9d4e023e3..8d973ecb4 100644 --- a/examples/flyer_chat/lib/create_message.dart +++ b/examples/flyer_chat/lib/create_message.dart @@ -15,7 +15,9 @@ Future createMessage( const uuid = Uuid(); Message message; - if (Random().nextBool() || textOnly == true || text != null) { + final randomType = Random().nextInt(3) + 1; // 1, 2, or 3 + + if (randomType == 1 || textOnly == true || text != null) { message = TextMessage( id: uuid.v4(), authorId: authorId, @@ -50,15 +52,26 @@ Future createMessage( ), ); - message = ImageMessage( - id: uuid.v4(), - authorId: authorId, - createdAt: DateTime.now().toUtc(), - sentAt: localOnly == true ? DateTime.now().toUtc() : null, - source: response.data['img'], - thumbhash: response.data['thumbhash'], - blurhash: response.data['blurhash'], - ); + if (randomType == 2) { + message = ImageMessage( + id: uuid.v4(), + authorId: authorId, + createdAt: DateTime.now().toUtc(), + sentAt: localOnly == true ? DateTime.now().toUtc() : null, + source: response.data['img'], + thumbhash: response.data['thumbhash'], + blurhash: response.data['blurhash'], + ); + } else { + message = FileMessage( + id: uuid.v4(), + name: 'image.png', + authorId: authorId, + createdAt: DateTime.now().toUtc(), + sentAt: localOnly == true ? DateTime.now().toUtc() : null, + source: response.data['img'], + ); + } } // return ImageMessage( diff --git a/examples/flyer_chat/lib/topwidgets_demo.dart b/examples/flyer_chat/lib/topwidgets_demo.dart index 200c54ba1..ffb526e73 100644 --- a/examples/flyer_chat/lib/topwidgets_demo.dart +++ b/examples/flyer_chat/lib/topwidgets_demo.dart @@ -138,7 +138,12 @@ class TopWidgetInBubbleState extends State { index, { required bool isSentByMe, MessageGroupStatus? groupStatus, - }) => FlyerChatFileMessage(message: message, index: index), + }) => FlyerChatFileMessage( + message: message, + index: index, + topWidgets: [_buildUsername(message, isSentByMe, groupStatus)], + timeAndStatusPosition: TimeAndStatusPosition.inline, + ), chatMessageBuilder: ( context, diff --git a/packages/flyer_chat_file_message/lib/src/flyer_chat_file_message.dart b/packages/flyer_chat_file_message/lib/src/flyer_chat_file_message.dart index e2c47917f..5aa0a2c13 100644 --- a/packages/flyer_chat_file_message/lib/src/flyer_chat_file_message.dart +++ b/packages/flyer_chat_file_message/lib/src/flyer_chat_file_message.dart @@ -74,6 +74,9 @@ class FlyerChatFileMessage extends StatelessWidget { /// Position of the timestamp and status indicator relative to the text content. final TimeAndStatusPosition timeAndStatusPosition; + /// The widgets to display before the message. + final List? topWidgets; + /// Creates a widget to display a file message. const FlyerChatFileMessage({ super.key, @@ -95,6 +98,7 @@ class FlyerChatFileMessage extends StatelessWidget { this.showTime = true, this.showStatus = true, this.timeAndStatusPosition = TimeAndStatusPosition.end, + this.topWidgets, }); @override @@ -190,42 +194,36 @@ class FlyerChatFileMessage extends StatelessWidget { required TimeAndStatus? timeAndStatus, required TextStyle? textStyle, }) { - if (timeAndStatus == null || - timeAndStatusPosition == TimeAndStatusPosition.inline) { - return fileContent; - } - final textDirection = Directionality.of(context); - switch (timeAndStatusPosition) { - case TimeAndStatusPosition.start: - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [fileContent, timeAndStatus], - ); - case TimeAndStatusPosition.inline: - return Row( + + return Stack( + children: [ + Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [fileContent, const SizedBox(width: 4), timeAndStatus], - ); - case TimeAndStatusPosition.end: - return Stack( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: EdgeInsets.only(bottom: textStyle?.lineHeight ?? 0), - child: fileContent, - ), - Opacity(opacity: 0, child: timeAndStatus), - Positioned.directional( - textDirection: textDirection, - end: 0, - bottom: 0, - child: timeAndStatus, - ), + if (topWidgets != null) ...topWidgets!, + // In comparison to other messages types, if timeAndStatusPosition is inline, + // the fileContent is already a Row with the timeAndStatus widget inside it. + fileContent, + if (timeAndStatusPosition != TimeAndStatusPosition.inline) + // Ensure the width is not smaller than the timeAndStatus widget + // Ensure the height accounts for it's height + Opacity(opacity: 0, child: timeAndStatus), ], - ); - } + ), + if (timeAndStatusPosition != TimeAndStatusPosition.inline && + timeAndStatus != null) + Positioned.directional( + textDirection: textDirection, + end: timeAndStatusPosition == TimeAndStatusPosition.end ? 0 : null, + start: + timeAndStatusPosition == TimeAndStatusPosition.start ? 0 : null, + bottom: 0, + child: timeAndStatus, + ), + ], + ); } String _formatFileSize(int sizeInBytes) { From e4f95797ec4014235a597d66fed7973111f1ce8d Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Fri, 4 Jul 2025 10:51:05 +0200 Subject: [PATCH 28/32] Allow text under FileMessages --- examples/flyer_chat/lib/create_message.dart | 1 + .../lib/src/models/message.dart | 3 ++ .../lib/src/models/message.freezed.dart | 17 +++++--- .../lib/src/models/message.g.dart | 2 + .../lib/src/flyer_chat_file_message.dart | 43 ++++++++++++++++--- 5 files changed, 54 insertions(+), 12 deletions(-) diff --git a/examples/flyer_chat/lib/create_message.dart b/examples/flyer_chat/lib/create_message.dart index 8d973ecb4..52078613b 100644 --- a/examples/flyer_chat/lib/create_message.dart +++ b/examples/flyer_chat/lib/create_message.dart @@ -66,6 +66,7 @@ Future createMessage( message = FileMessage( id: uuid.v4(), name: 'image.png', + text: 'This is a file message', authorId: authorId, createdAt: DateTime.now().toUtc(), sentAt: localOnly == true ? DateTime.now().toUtc() : null, diff --git a/packages/flutter_chat_core/lib/src/models/message.dart b/packages/flutter_chat_core/lib/src/models/message.dart index 79bce33c9..b9022a621 100644 --- a/packages/flutter_chat_core/lib/src/models/message.dart +++ b/packages/flutter_chat_core/lib/src/models/message.dart @@ -240,6 +240,9 @@ sealed class Message with _$Message { /// Name of the file. required String name, + /// Optional text accompanying the file. + String? text, + /// Size of the file in bytes. int? size, diff --git a/packages/flutter_chat_core/lib/src/models/message.freezed.dart b/packages/flutter_chat_core/lib/src/models/message.freezed.dart index c360092a4..4661e57d8 100644 --- a/packages/flutter_chat_core/lib/src/models/message.freezed.dart +++ b/packages/flutter_chat_core/lib/src/models/message.freezed.dart @@ -596,7 +596,7 @@ as bool?, @JsonSerializable() class FileMessage extends Message { - const FileMessage({required this.id, required this.authorId, this.replyToMessageId, @EpochDateTimeConverter() this.createdAt, @EpochDateTimeConverter() this.deletedAt, @EpochDateTimeConverter() this.failedAt, @EpochDateTimeConverter() this.sentAt, @EpochDateTimeConverter() this.deliveredAt, @EpochDateTimeConverter() this.seenAt, @EpochDateTimeConverter() this.updatedAt, final Map>? reactions, this.pinned, final Map? metadata, this.status, required this.source, required this.name, this.size, this.mimeType, final String? $type}): _reactions = reactions,_metadata = metadata,$type = $type ?? 'file',super._(); + const FileMessage({required this.id, required this.authorId, this.replyToMessageId, @EpochDateTimeConverter() this.createdAt, @EpochDateTimeConverter() this.deletedAt, @EpochDateTimeConverter() this.failedAt, @EpochDateTimeConverter() this.sentAt, @EpochDateTimeConverter() this.deliveredAt, @EpochDateTimeConverter() this.seenAt, @EpochDateTimeConverter() this.updatedAt, final Map>? reactions, this.pinned, final Map? metadata, this.status, required this.source, required this.name, this.text, this.size, this.mimeType, final String? $type}): _reactions = reactions,_metadata = metadata,$type = $type ?? 'file',super._(); factory FileMessage.fromJson(Map json) => _$FileMessageFromJson(json); /// Unique identifier for the message. @@ -650,6 +650,8 @@ class FileMessage extends Message { final String source; /// Name of the file. final String name; +/// Optional text caption accompanying the file. + final String? text; /// Size of the file in bytes. final int? size; /// MIME type of the file. @@ -672,16 +674,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is FileMessage&&(identical(other.id, id) || other.id == id)&&(identical(other.authorId, authorId) || other.authorId == authorId)&&(identical(other.replyToMessageId, replyToMessageId) || other.replyToMessageId == replyToMessageId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.failedAt, failedAt) || other.failedAt == failedAt)&&(identical(other.sentAt, sentAt) || other.sentAt == sentAt)&&(identical(other.deliveredAt, deliveredAt) || other.deliveredAt == deliveredAt)&&(identical(other.seenAt, seenAt) || other.seenAt == seenAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&const DeepCollectionEquality().equals(other._reactions, _reactions)&&(identical(other.pinned, pinned) || other.pinned == pinned)&&const DeepCollectionEquality().equals(other._metadata, _metadata)&&(identical(other.status, status) || other.status == status)&&(identical(other.source, source) || other.source == source)&&(identical(other.name, name) || other.name == name)&&(identical(other.size, size) || other.size == size)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is FileMessage&&(identical(other.id, id) || other.id == id)&&(identical(other.authorId, authorId) || other.authorId == authorId)&&(identical(other.replyToMessageId, replyToMessageId) || other.replyToMessageId == replyToMessageId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.failedAt, failedAt) || other.failedAt == failedAt)&&(identical(other.sentAt, sentAt) || other.sentAt == sentAt)&&(identical(other.deliveredAt, deliveredAt) || other.deliveredAt == deliveredAt)&&(identical(other.seenAt, seenAt) || other.seenAt == seenAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&const DeepCollectionEquality().equals(other._reactions, _reactions)&&(identical(other.pinned, pinned) || other.pinned == pinned)&&const DeepCollectionEquality().equals(other._metadata, _metadata)&&(identical(other.status, status) || other.status == status)&&(identical(other.source, source) || other.source == source)&&(identical(other.name, name) || other.name == name)&&(identical(other.text, text) || other.text == text)&&(identical(other.size, size) || other.size == size)&&(identical(other.mimeType, mimeType) || other.mimeType == mimeType)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,id,authorId,replyToMessageId,createdAt,deletedAt,failedAt,sentAt,deliveredAt,seenAt,updatedAt,const DeepCollectionEquality().hash(_reactions),pinned,const DeepCollectionEquality().hash(_metadata),status,source,name,size,mimeType); +int get hashCode => Object.hashAll([runtimeType,id,authorId,replyToMessageId,createdAt,deletedAt,failedAt,sentAt,deliveredAt,seenAt,updatedAt,const DeepCollectionEquality().hash(_reactions),pinned,const DeepCollectionEquality().hash(_metadata),status,source,name,text,size,mimeType]); @override String toString() { - return 'Message.file(id: $id, authorId: $authorId, replyToMessageId: $replyToMessageId, createdAt: $createdAt, deletedAt: $deletedAt, failedAt: $failedAt, sentAt: $sentAt, deliveredAt: $deliveredAt, seenAt: $seenAt, updatedAt: $updatedAt, reactions: $reactions, pinned: $pinned, metadata: $metadata, status: $status, source: $source, name: $name, size: $size, mimeType: $mimeType)'; + return 'Message.file(id: $id, authorId: $authorId, replyToMessageId: $replyToMessageId, createdAt: $createdAt, deletedAt: $deletedAt, failedAt: $failedAt, sentAt: $sentAt, deliveredAt: $deliveredAt, seenAt: $seenAt, updatedAt: $updatedAt, reactions: $reactions, pinned: $pinned, metadata: $metadata, status: $status, source: $source, name: $name, text: $text, size: $size, mimeType: $mimeType)'; } @@ -692,7 +694,7 @@ abstract mixin class $FileMessageCopyWith<$Res> implements $MessageCopyWith<$Res factory $FileMessageCopyWith(FileMessage value, $Res Function(FileMessage) _then) = _$FileMessageCopyWithImpl; @override @useResult $Res call({ - MessageID id, UserID authorId, MessageID? replyToMessageId,@EpochDateTimeConverter() DateTime? createdAt,@EpochDateTimeConverter() DateTime? deletedAt,@EpochDateTimeConverter() DateTime? failedAt,@EpochDateTimeConverter() DateTime? sentAt,@EpochDateTimeConverter() DateTime? deliveredAt,@EpochDateTimeConverter() DateTime? seenAt,@EpochDateTimeConverter() DateTime? updatedAt, Map>? reactions, bool? pinned, Map? metadata, MessageStatus? status, String source, String name, int? size, String? mimeType + MessageID id, UserID authorId, MessageID? replyToMessageId,@EpochDateTimeConverter() DateTime? createdAt,@EpochDateTimeConverter() DateTime? deletedAt,@EpochDateTimeConverter() DateTime? failedAt,@EpochDateTimeConverter() DateTime? sentAt,@EpochDateTimeConverter() DateTime? deliveredAt,@EpochDateTimeConverter() DateTime? seenAt,@EpochDateTimeConverter() DateTime? updatedAt, Map>? reactions, bool? pinned, Map? metadata, MessageStatus? status, String source, String name, String? text, int? size, String? mimeType }); @@ -709,7 +711,7 @@ class _$FileMessageCopyWithImpl<$Res> /// Create a copy of Message /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? authorId = null,Object? replyToMessageId = freezed,Object? createdAt = freezed,Object? deletedAt = freezed,Object? failedAt = freezed,Object? sentAt = freezed,Object? deliveredAt = freezed,Object? seenAt = freezed,Object? updatedAt = freezed,Object? reactions = freezed,Object? pinned = freezed,Object? metadata = freezed,Object? status = freezed,Object? source = null,Object? name = null,Object? size = freezed,Object? mimeType = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? authorId = null,Object? replyToMessageId = freezed,Object? createdAt = freezed,Object? deletedAt = freezed,Object? failedAt = freezed,Object? sentAt = freezed,Object? deliveredAt = freezed,Object? seenAt = freezed,Object? updatedAt = freezed,Object? reactions = freezed,Object? pinned = freezed,Object? metadata = freezed,Object? status = freezed,Object? source = null,Object? name = null,Object? text = freezed,Object? size = freezed,Object? mimeType = freezed,}) { return _then(FileMessage( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as MessageID,authorId: null == authorId ? _self.authorId : authorId // ignore: cast_nullable_to_non_nullable @@ -727,7 +729,8 @@ as bool?,metadata: freezed == metadata ? _self._metadata : metadata // ignore: c as Map?,status: freezed == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as MessageStatus?,source: null == source ? _self.source : source // ignore: cast_nullable_to_non_nullable as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable -as String,size: freezed == size ? _self.size : size // ignore: cast_nullable_to_non_nullable +as String,text: freezed == text ? _self.text : text // ignore: cast_nullable_to_non_nullable +as String?,size: freezed == size ? _self.size : size // ignore: cast_nullable_to_non_nullable as int?,mimeType: freezed == mimeType ? _self.mimeType : mimeType // ignore: cast_nullable_to_non_nullable as String?, )); diff --git a/packages/flutter_chat_core/lib/src/models/message.g.dart b/packages/flutter_chat_core/lib/src/models/message.g.dart index d14292f2e..8f9fe0cad 100644 --- a/packages/flutter_chat_core/lib/src/models/message.g.dart +++ b/packages/flutter_chat_core/lib/src/models/message.g.dart @@ -398,6 +398,7 @@ FileMessage _$FileMessageFromJson(Map json) => FileMessage( status: $enumDecodeNullable(_$MessageStatusEnumMap, json['status']), source: json['source'] as String, name: json['name'] as String, + text: json['text'] as String?, size: (json['size'] as num?)?.toInt(), mimeType: json['mimeType'] as String?, $type: json['type'] as String?, @@ -458,6 +459,7 @@ Map _$FileMessageToJson( 'status': value, 'source': instance.source, 'name': instance.name, + if (instance.text case final value?) 'text': value, if (instance.size case final value?) 'size': value, if (instance.mimeType case final value?) 'mimeType': value, 'type': instance.$type, diff --git a/packages/flyer_chat_file_message/lib/src/flyer_chat_file_message.dart b/packages/flyer_chat_file_message/lib/src/flyer_chat_file_message.dart index 5aa0a2c13..8826bfe4c 100644 --- a/packages/flyer_chat_file_message/lib/src/flyer_chat_file_message.dart +++ b/packages/flyer_chat_file_message/lib/src/flyer_chat_file_message.dart @@ -62,6 +62,12 @@ class FlyerChatFileMessage extends StatelessWidget { /// Text style for the file size in messages received from other users. final TextStyle? receivedSizeTextStyle; + /// Text style for the accompanying text sent by the current user. + final TextStyle? sentTextStyle; + + /// Text style for the accompanying text received from other users. + final TextStyle? receivedTextStyle; + /// Text style for the message timestamp and status. final TextStyle? timeStyle; @@ -94,6 +100,8 @@ class FlyerChatFileMessage extends StatelessWidget { this.receivedNameTextStyle, this.sentSizeTextStyle, this.receivedSizeTextStyle, + this.sentTextStyle, + this.receivedTextStyle, this.timeStyle, this.showTime = true, this.showStatus = true, @@ -119,6 +127,7 @@ class FlyerChatFileMessage extends StatelessWidget { final backgroundColor = _resolveBackgroundColor(isSentByMe, theme); final nameTextStyle = _resolveNameTextStyle(isSentByMe, theme); final sizeTextStyle = _resolveSizeTextStyle(isSentByMe, theme); + final textTextStyle = _resolveParagraphStyle(isSentByMe, theme); final timeStyle = _resolveTimeStyle(isSentByMe, theme); final timeAndStatus = @@ -148,7 +157,8 @@ class FlyerChatFileMessage extends StatelessWidget { overflow: TextOverflow.ellipsis, ), timeAndStatus == null || - timeAndStatusPosition != TimeAndStatusPosition.inline + timeAndStatusPosition != TimeAndStatusPosition.inline || + message.text != null ? sizeContent : Row( mainAxisSize: MainAxisSize.min, @@ -180,7 +190,7 @@ class FlyerChatFileMessage extends StatelessWidget { context: context, fileContent: fileContent, timeAndStatus: timeAndStatus, - textStyle: sizeTextStyle, + textStyle: textTextStyle, ), ), ], @@ -203,9 +213,24 @@ class FlyerChatFileMessage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (topWidgets != null) ...topWidgets!, - // In comparison to other messages types, if timeAndStatusPosition is inline, - // the fileContent is already a Row with the timeAndStatus widget inside it. - fileContent, + if (timeAndStatusPosition == TimeAndStatusPosition.inline && + message.text != null) ...[ + fileContent, + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible(child: Text(message.text!, style: textStyle)), + SizedBox(width: 4), + Padding( + // TODO? add timeAndStatusPositionInlineInsets + padding: EdgeInsets.zero, + child: timeAndStatus, + ), + ], + ), + ] else + fileContent, if (timeAndStatusPosition != TimeAndStatusPosition.inline) // Ensure the width is not smaller than the timeAndStatus widget // Ensure the height accounts for it's height @@ -277,6 +302,14 @@ class FlyerChatFileMessage extends StatelessWidget { theme.bodySmall.copyWith(color: theme.onSurface.withValues(alpha: 0.8)); } + TextStyle? _resolveParagraphStyle(bool isSentByMe, _LocalTheme theme) { + if (isSentByMe) { + return sentTextStyle ?? theme.bodyMedium.copyWith(color: theme.onPrimary); + } + return receivedTextStyle ?? + theme.bodyMedium.copyWith(color: theme.onSurface); + } + TextStyle? _resolveTimeStyle(bool isSentByMe, _LocalTheme theme) { final color = isSentByMe ? theme.onPrimary : theme.onSurface; From 1c33a55d32a0430eb0f341ec8f1af3cd53c62ebe Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Fri, 4 Jul 2025 11:21:58 +0200 Subject: [PATCH 29/32] Add support in ImageMessages --- examples/flyer_chat/lib/create_message.dart | 1 + .../lib/src/flyer_chat_image_message.dart | 121 +++++++++++++++++- 2 files changed, 116 insertions(+), 6 deletions(-) diff --git a/examples/flyer_chat/lib/create_message.dart b/examples/flyer_chat/lib/create_message.dart index 52078613b..7740842a3 100644 --- a/examples/flyer_chat/lib/create_message.dart +++ b/examples/flyer_chat/lib/create_message.dart @@ -56,6 +56,7 @@ Future createMessage( message = ImageMessage( id: uuid.v4(), authorId: authorId, + text: 'This is an image message', createdAt: DateTime.now().toUtc(), sentAt: localOnly == true ? DateTime.now().toUtc() : null, source: response.data['img'], 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 47b455b80..acb79ebd7 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 @@ -15,6 +15,8 @@ import 'get_image_dimensions.dart'; typedef _LocalTheme = ({ TextStyle labelSmall, + TextStyle bodyMedium, + Color onPrimary, Color onSurface, Color primary, BorderRadiusGeometry shape, @@ -97,6 +99,15 @@ class FlyerChatImageMessage extends StatefulWidget { /// The widgets to display before the message. final List? topWidgets; + /// Text style for the accompanying text sent by the current user. + final TextStyle? sentTextStyle; + + /// Text style for the accompanying text received from other users. + final TextStyle? receivedTextStyle; + + /// Padding for the accompanying text. + final EdgeInsetsGeometry? textPadding; + /// Creates a widget to display an image message. const FlyerChatImageMessage({ super.key, @@ -121,6 +132,9 @@ class FlyerChatImageMessage extends StatefulWidget { this.sentBackgroundColor, this.receivedBackgroundColor, this.topWidgets, + this.sentTextStyle, + this.receivedTextStyle, + this.textPadding = const EdgeInsets.symmetric(horizontal: 16, vertical: 10), }); @override @@ -215,11 +229,35 @@ class _FlyerChatImageMessageState extends State return widget.receivedBackgroundColor ?? theme.surfaceContainer; } + TextStyle? _resolveParagraphStyle(bool isSentByMe, _LocalTheme theme) { + if (isSentByMe) { + return widget.sentTextStyle ?? + theme.bodyMedium.copyWith(color: theme.onPrimary); + } + return widget.receivedTextStyle ?? + theme.bodyMedium.copyWith(color: theme.onSurface); + } + + TextStyle? _resolveTimeStyle(bool isSentByMe, _LocalTheme theme) { + if (isSentByMe) { + return widget.timeStyle ?? + theme.labelSmall.copyWith( + color: widget.message.text != null ? theme.onPrimary : Colors.white, + ); + } + return widget.timeStyle ?? + theme.labelSmall.copyWith( + color: widget.message.text != null ? theme.onSurface : Colors.white, + ); + } + @override Widget build(BuildContext context) { final theme = context.select( (ChatTheme t) => ( labelSmall: t.typography.labelSmall, + bodyMedium: t.typography.bodyMedium, + onPrimary: t.colors.onPrimary, onSurface: t.colors.onSurface, primary: t.colors.primary, surfaceContainer: t.colors.surfaceContainer, @@ -237,10 +275,12 @@ class _FlyerChatImageMessageState extends State showTime: widget.showTime, showStatus: isSentByMe && widget.showStatus, backgroundColor: - widget.timeBackground ?? Colors.black.withValues(alpha: 0.6), - textStyle: - widget.timeStyle ?? - theme.labelSmall.copyWith(color: Colors.white), + widget.message.text == null + ? widget.timeBackground ?? + Colors.black.withValues(alpha: 0.6) + : null, + textStyle: _resolveTimeStyle(isSentByMe, theme), + padding: widget.message.text == null ? null : EdgeInsets.zero, ) : null; @@ -351,7 +391,7 @@ class _FlyerChatImageMessageState extends State ); }, ), - if (timeAndStatus != null) + if (timeAndStatus != null && widget.message.text == null) Positioned.directional( textDirection: textDirection, bottom: 8, @@ -373,12 +413,77 @@ class _FlyerChatImageMessageState extends State ), ), ), + if (widget.message.text != null) + Padding( + padding: widget.textPadding!, + child: _buildTextContentBasedOnPosition( + context: context, + text: widget.message.text!, + timeAndStatus: timeAndStatus, + paragraphStyle: _resolveParagraphStyle(isSentByMe, theme), + ), + ), ], ), ), ); } + Widget _buildTextContentBasedOnPosition({ + required BuildContext context, + required String text, + TimeAndStatus? timeAndStatus, + TextStyle? paragraphStyle, + }) { + final textDirection = Directionality.of(context); + + return Stack( + children: [ + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + widget.timeAndStatusPosition == TimeAndStatusPosition.inline + ? Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible(child: Text(text, style: paragraphStyle)), + SizedBox(width: 4), + Padding( + padding: + // TODO? timeAndStatusPositionInlineInsets + EdgeInsets.zero, + child: timeAndStatus, + ), + ], + ) + : Text(text, style: paragraphStyle), + if (widget.timeAndStatusPosition != TimeAndStatusPosition.inline) + // Ensure the width is not smaller than the timeAndStatus widget + // Ensure the height accounts for it's height + Opacity(opacity: 0, child: timeAndStatus), + ], + ), + if (widget.timeAndStatusPosition != TimeAndStatusPosition.inline && + timeAndStatus != null) + Positioned.directional( + textDirection: textDirection, + end: + widget.timeAndStatusPosition == TimeAndStatusPosition.end + ? 0 + : null, + start: + widget.timeAndStatusPosition == TimeAndStatusPosition.start + ? 0 + : null, + bottom: 0, + child: timeAndStatus, + ), + ], + ); + } + ImageProvider get _targetProvider { if (widget.customImageProvider != null) { return widget.customImageProvider!; @@ -413,6 +518,9 @@ class TimeAndStatus extends StatelessWidget { /// Text style for the time and status. final TextStyle? textStyle; + /// Padding for the time and status container. + final EdgeInsetsGeometry? padding; + /// Creates a widget for displaying time and status over an image. const TimeAndStatus({ super.key, @@ -422,6 +530,7 @@ class TimeAndStatus extends StatelessWidget { this.showStatus = true, this.backgroundColor, this.textStyle, + this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 4), }); @override @@ -429,7 +538,7 @@ class TimeAndStatus extends StatelessWidget { final timeFormat = context.watch(); return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: padding, decoration: BoxDecoration( color: backgroundColor, borderRadius: BorderRadius.circular(12), From 69541c96fb88b6dcd330db663a0c13d8996789a5 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Fri, 4 Jul 2025 11:22:45 +0200 Subject: [PATCH 30/32] Add Center to the AspectRatio to fix when text is wider than image --- .../lib/src/flyer_chat_image_message.dart | 202 +++++++++--------- 1 file changed, 105 insertions(+), 97 deletions(-) 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 acb79ebd7..f1e4235cd 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 @@ -295,121 +295,129 @@ class _FlyerChatImageMessageState extends State children: [ if (widget.topWidgets != null) ...widget.topWidgets!, Flexible( - child: AspectRatio( - aspectRatio: _aspectRatio, - child: Stack( - fit: StackFit.expand, - children: [ - _placeholderProvider != null - ? Image(image: _placeholderProvider!, fit: BoxFit.fill) - : Container( - color: - widget.placeholderColor ?? - theme.surfaceContainerLow, - ), - Image( - image: _imageProvider, - fit: BoxFit.fill, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) { - return child; - } - - return Container( - color: - widget.loadingOverlayColor ?? - theme.surfaceContainerLow.withValues(alpha: 0.5), - child: Center( - child: CircularProgressIndicator( - color: - widget.loadingIndicatorColor ?? - theme.onSurface.withValues(alpha: 0.8), - strokeCap: StrokeCap.round, - value: - loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - ), + child: Center( + child: AspectRatio( + aspectRatio: _aspectRatio, + child: Stack( + fit: StackFit.expand, + children: [ + _placeholderProvider != null + ? Image( + image: _placeholderProvider!, + fit: BoxFit.fill, + ) + : Container( + color: + widget.placeholderColor ?? + theme.surfaceContainerLow, ), - ); - }, - 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, - ); - }, - errorBuilder: widget.errorBuilder, - ), - 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(); + Image( + image: _imageProvider, + fit: BoxFit.fill, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) { + return child; } return Container( color: - widget.uploadOverlayColor ?? + widget.loadingOverlayColor ?? theme.surfaceContainerLow.withValues( alpha: 0.5, ), child: Center( child: CircularProgressIndicator( color: - widget.uploadIndicatorColor ?? + widget.loadingIndicatorColor ?? theme.onSurface.withValues(alpha: 0.8), strokeCap: StrokeCap.round, - value: snapshot.data, + 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, + ); + }, + errorBuilder: widget.errorBuilder, ), - if (timeAndStatus != null && widget.message.text == 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, - ), - ], + 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.surfaceContainerLow.withValues( + alpha: 0.5, + ), + child: Center( + child: CircularProgressIndicator( + color: + widget.uploadIndicatorColor ?? + theme.onSurface.withValues(alpha: 0.8), + strokeCap: StrokeCap.round, + value: snapshot.data, + ), + ), + ); + }, + ), + if (timeAndStatus != null && widget.message.text == 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, + ), + ], + ), ), ), ), From 4c80d43b24e6517412caef1a1db3eb7a34eb103c Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Fri, 4 Jul 2025 13:02:33 +0200 Subject: [PATCH 31/32] Fix Image rendering --- .../lib/src/flyer_chat_image_message.dart | 358 +++++++++--------- 1 file changed, 180 insertions(+), 178 deletions(-) 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 f1e4235cd..68a8b6cbc 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 @@ -280,218 +280,220 @@ class _FlyerChatImageMessageState extends State Colors.black.withValues(alpha: 0.6) : null, textStyle: _resolveTimeStyle(isSentByMe, theme), - padding: widget.message.text == null ? null : EdgeInsets.zero, + padding: + widget.message.text == null + ? const EdgeInsets.symmetric(horizontal: 8, vertical: 4) + : EdgeInsets.zero, ) : null; + final Widget imageContent = AspectRatio( + aspectRatio: _aspectRatio, + child: Stack( + fit: StackFit.expand, + children: [ + _placeholderProvider != null + ? Image(image: _placeholderProvider!, fit: BoxFit.fill) + : Container( + color: widget.placeholderColor ?? theme.surfaceContainerLow, + ), + Image( + image: _imageProvider, + fit: BoxFit.fill, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) { + return child; + } + + return Container( + color: + widget.loadingOverlayColor ?? + theme.surfaceContainerLow.withValues(alpha: 0.5), + child: Center( + child: CircularProgressIndicator( + color: + widget.loadingIndicatorColor ?? + theme.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, + ); + }, + errorBuilder: widget.errorBuilder, + ), + 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.surfaceContainerLow.withValues(alpha: 0.5), + child: Center( + child: CircularProgressIndicator( + color: + widget.uploadIndicatorColor ?? + theme.onSurface.withValues(alpha: 0.8), + strokeCap: StrokeCap.round, + value: snapshot.data, + ), + ), + ); + }, + ), + if (timeAndStatus != null && widget.message.text == 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, + ), + ], + ), + ); + + final Widget content = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.topWidgets != null) ...widget.topWidgets!, + Flexible( + child: Row( + mainAxisSize: MainAxisSize.max, + children: [Expanded(child: imageContent)], + ), + ), + if (widget.message.text != null) + Padding( + padding: widget.textPadding!, + child: _buildTextContentBasedOnPosition( + context: context, + text: widget.message.text!, + timeAndStatus: timeAndStatus, + paragraphStyle: _resolveParagraphStyle(isSentByMe, theme), + ), + ), + if (widget.timeAndStatusPosition != TimeAndStatusPosition.inline && + widget.message.text != null) + // Ensure the width is not smaller than the timeAndStatus widget + // Ensure the height accounts for it's height + Opacity(opacity: 0, child: timeAndStatus), + ], + ); + return ClipRRect( borderRadius: widget.borderRadius ?? theme.shape, child: Container( constraints: widget.constraints, color: _resolveBackgroundColor(isSentByMe, theme), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (widget.topWidgets != null) ...widget.topWidgets!, - Flexible( - child: Center( - child: AspectRatio( - aspectRatio: _aspectRatio, - child: Stack( - fit: StackFit.expand, - children: [ - _placeholderProvider != null - ? Image( - image: _placeholderProvider!, - fit: BoxFit.fill, - ) - : Container( - color: - widget.placeholderColor ?? - theme.surfaceContainerLow, - ), - Image( - image: _imageProvider, - fit: BoxFit.fill, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) { - return child; - } - - return Container( - color: - widget.loadingOverlayColor ?? - theme.surfaceContainerLow.withValues( - alpha: 0.5, - ), - child: Center( - child: CircularProgressIndicator( - color: - widget.loadingIndicatorColor ?? - theme.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, - ); - }, - errorBuilder: widget.errorBuilder, - ), - 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.surfaceContainerLow.withValues( - alpha: 0.5, - ), - child: Center( - child: CircularProgressIndicator( - color: - widget.uploadIndicatorColor ?? - theme.onSurface.withValues(alpha: 0.8), - strokeCap: StrokeCap.round, - value: snapshot.data, - ), - ), - ); - }, - ), - if (timeAndStatus != null && widget.message.text == 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, - ), - ], - ), - ), - ), - ), - if (widget.message.text != null) - Padding( - padding: widget.textPadding!, - child: _buildTextContentBasedOnPosition( - context: context, - text: widget.message.text!, - timeAndStatus: timeAndStatus, - paragraphStyle: _resolveParagraphStyle(isSentByMe, theme), - ), - ), - ], + child: _buildContentBasedOnPosition( + context, + content, + timeAndStatus, + _resolveParagraphStyle(isSentByMe, theme), ), ), ); } - Widget _buildTextContentBasedOnPosition({ - required BuildContext context, - required String text, + Widget _buildContentBasedOnPosition( + BuildContext context, + Widget content, TimeAndStatus? timeAndStatus, TextStyle? paragraphStyle, - }) { + ) { final textDirection = Directionality.of(context); return Stack( children: [ - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - widget.timeAndStatusPosition == TimeAndStatusPosition.inline - ? Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Flexible(child: Text(text, style: paragraphStyle)), - SizedBox(width: 4), - Padding( - padding: - // TODO? timeAndStatusPositionInlineInsets - EdgeInsets.zero, - child: timeAndStatus, - ), - ], - ) - : Text(text, style: paragraphStyle), - if (widget.timeAndStatusPosition != TimeAndStatusPosition.inline) - // Ensure the width is not smaller than the timeAndStatus widget - // Ensure the height accounts for it's height - Opacity(opacity: 0, child: timeAndStatus), - ], - ), + content, if (widget.timeAndStatusPosition != TimeAndStatusPosition.inline && - timeAndStatus != null) + timeAndStatus != null && + widget.message.text != null) Positioned.directional( textDirection: textDirection, end: widget.timeAndStatusPosition == TimeAndStatusPosition.end - ? 0 + ? widget.textPadding!.horizontal / 2 : null, start: widget.timeAndStatusPosition == TimeAndStatusPosition.start - ? 0 + ? widget.textPadding!.horizontal / 2 : null, - bottom: 0, + bottom: widget.textPadding!.vertical / 2, child: timeAndStatus, ), ], ); } + Widget _buildTextContentBasedOnPosition({ + required BuildContext context, + required String text, + TimeAndStatus? timeAndStatus, + TextStyle? paragraphStyle, + }) { + final textContent = Text(text, style: paragraphStyle); + return widget.timeAndStatusPosition == TimeAndStatusPosition.inline + ? Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Flexible(child: textContent), + SizedBox(width: 4), + Padding( + padding: + // TODO? timeAndStatusPositionInlineInsets + EdgeInsets.zero, + child: timeAndStatus, + ), + ], + ) + : textContent; + } + ImageProvider get _targetProvider { if (widget.customImageProvider != null) { return widget.customImageProvider!; From 978dcdd03b2a8a3a2d53e0eaf571bb6cc9c04d38 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Fri, 4 Jul 2025 14:18:27 +0200 Subject: [PATCH 32/32] ImageMessage: Wrap in Row only if message has text --- .../lib/src/flyer_chat_image_message.dart | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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 68a8b6cbc..1a16d9b2e 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 @@ -400,10 +400,13 @@ class _FlyerChatImageMessageState extends State children: [ if (widget.topWidgets != null) ...widget.topWidgets!, Flexible( - child: Row( - mainAxisSize: MainAxisSize.max, - children: [Expanded(child: imageContent)], - ), + child: + widget.message.text != null + ? Row( + mainAxisSize: MainAxisSize.max, + children: [Expanded(child: imageContent)], + ) + : imageContent, ), if (widget.message.text != null) Padding(