From 20c37cb51a20947abac97fdfe58fd53dbf2f5dbd Mon Sep 17 00:00:00 2001 From: Christian Pauly Date: Fri, 29 Jul 2022 18:24:59 +0200 Subject: [PATCH] refactor: Simplify MxcImage and replace CachedNetworkImage --- lib/pages/chat/events/image_bubble.dart | 481 ++---------------- lib/pages/chat/events/message.dart | 9 +- lib/pages/chat/events/message_content.dart | 1 + lib/pages/chat/events/message_reactions.dart | 13 +- lib/pages/chat/input_bar.dart | 16 +- lib/pages/connect/sso_button.dart | 5 +- lib/pages/image_viewer/image_viewer.dart | 3 +- lib/pages/image_viewer/image_viewer_view.dart | 19 +- .../settings_emotes/settings_emotes_view.dart | 13 +- lib/widgets/avatar.dart | 19 +- lib/widgets/content_banner.dart | 64 +-- lib/widgets/mxc_image.dart | 147 ++++++ pubspec.lock | 2 +- pubspec.yaml | 1 - 14 files changed, 255 insertions(+), 538 deletions(-) create mode 100644 lib/widgets/mxc_image.dart diff --git a/lib/pages/chat/events/image_bubble.dart b/lib/pages/chat/events/image_bubble.dart index 27872f326..787c65846 100644 --- a/lib/pages/chat/events/image_bubble.dart +++ b/lib/pages/chat/events/image_bubble.dart @@ -1,20 +1,13 @@ -import 'dart:typed_data'; - import 'package:flutter/material.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_blurhash/flutter_blurhash.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:lottie/lottie.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/pages/image_viewer/image_viewer.dart'; import 'package:fluffychat/widgets/matrix.dart'; -import '../../../utils/matrix_sdk_extensions.dart/event_extension.dart'; +import 'package:fluffychat/widgets/mxc_image.dart'; -class ImageBubble extends StatefulWidget { +class ImageBubble extends StatelessWidget { final Event event; final bool tapToView; final BoxFit fit; @@ -24,7 +17,6 @@ class ImageBubble extends StatefulWidget { final bool animated; final double width; final double height; - final void Function()? onLoaded; final void Function()? onTap; const ImageBubble( @@ -34,7 +26,6 @@ class ImageBubble extends StatefulWidget { this.backgroundColor, this.fit = BoxFit.cover, this.thumbnailOnly = true, - this.onLoaded, this.width = 400, this.height = 300, this.animated = false, @@ -42,446 +33,78 @@ class ImageBubble extends StatefulWidget { Key? key, }) : super(key: key); - @override - _ImageBubbleState createState() => _ImageBubbleState(); -} - -class _ImageBubbleState extends State { - // for plaintext: holds the http URL for the thumbnail - String? thumbnailUrl; - // for plaintext. holds the http URL for the thumbnial, without the animated flag - String? thumbnailUrlNoAnimated; - // for plaintext: holds the http URL of the original - String? attachmentUrl; - MatrixFile? _file; - MatrixFile? _thumbnail; - bool _requestedThumbnailOnFailure = false; - // In case we have animated = false, this will hold the first frame so that we make - // sure that things are never animated - Widget? _firstFrame; - - // the mimetypes that we know how to render, from the flutter Image widget - final _knownMimetypes = { - 'image/jpg', - 'image/jpeg', - 'image/png', - 'image/apng', - 'image/webp', - 'image/gif', - 'image/bmp', - 'image/x-bmp', - }; - - // overrides for certain mimetypes if they need different images to render - // memory are for in-memory renderers (e2ee rooms), network for network url renderers. - // The map values themself are set in initState() as they need to be able to access - // `this`. - final _contentRenderers = {}; - - String getMimetype([bool thumbnail = false]) => thumbnail - ? widget.event.thumbnailMimetype.toLowerCase() - : widget.event.attachmentMimetype.toLowerCase(); - - MatrixFile? get _displayFile => _file ?? _thumbnail; - String? get displayUrl => widget.thumbnailOnly ? thumbnailUrl : attachmentUrl; - - dynamic _error; - - Future _requestFile({bool getThumbnail = false}) async { - try { - final res = await widget.event - .downloadAndDecryptAttachmentCached(getThumbnail: getThumbnail); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (getThumbnail) { - if (mounted) { - setState(() => _thumbnail = res); - } - } else { - if (widget.onLoaded != null) { - widget.onLoaded!(); - } - if (mounted) { - setState(() => _file = res); - } - } - }); - } catch (err) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - setState(() => _error = err); - } - }); - } - } - - Widget frameBuilder(_, Widget child, int? frame, __) { - // as servers might return animated gifs as thumbnails and we want them to *not* play - // animated, we'll have to store the first frame in a variable and display that instead - if (widget.animated) { - return child; - } - if (frame == 0) { - _firstFrame = child; - } - return _firstFrame ?? child; - } - - @override - void initState() { - // add the custom renderers for other mimetypes - _contentRenderers['image/svg+xml'] = _ImageBubbleContentRenderer( - memory: (Uint8List bytes, String key) => SvgPicture.memory( - bytes, - key: ValueKey(key), - fit: widget.fit, - ), - network: (String? url) => url == null - ? Container() - : SvgPicture.network( - url, - key: ValueKey(url), - placeholderBuilder: (context) => getPlaceholderWidget(), - fit: widget.fit, - ), - ); - _contentRenderers['image/lottie+json'] = _ImageBubbleContentRenderer( - memory: (Uint8List bytes, String key) => Lottie.memory( - bytes, - key: ValueKey(key), - fit: widget.fit, - errorBuilder: (context, error, stacktrace) => - getErrorWidget(context, error), - animate: widget.animated, - ), - network: (String? url) => url == null - ? Container() - : Lottie.network( - url, - key: ValueKey(url), - fit: widget.fit, - errorBuilder: (context, error, stacktrace) => - getErrorWidget(context, error), - animate: widget.animated, - ), - ); - - // add all the custom content renderer mimetypes to the known mimetypes set - for (final key in _contentRenderers.keys) { - _knownMimetypes.add(key); - } - - thumbnailUrl = widget.event - .getAttachmentUrl(getThumbnail: true, animated: widget.animated) - ?.toString(); - thumbnailUrlNoAnimated = widget.event - .getAttachmentUrl(getThumbnail: true, animated: false) - ?.toString(); - attachmentUrl = - widget.event.getAttachmentUrl(animated: widget.animated)?.toString(); - if (thumbnailUrl == null) { - _requestFile(getThumbnail: true); + Widget _buildPlaceholder(BuildContext context) { + if (event.messageType == MessageTypes.Sticker) { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); } - if (!widget.thumbnailOnly && attachmentUrl == null) { - _requestFile(); + final String blurHashString = + event.infoMap['xyz.amorgan.blurhash'] is String + ? event.infoMap['xyz.amorgan.blurhash'] + : 'LEHV6nWB2yk8pyo0adR*.7kCMdnj'; + final ratio = event.infoMap['w'] is int && event.infoMap['h'] is int + ? event.infoMap['w'] / event.infoMap['h'] + : 1.0; + var width = 32; + var height = 32; + if (ratio > 1.0) { + height = (width / ratio).round(); } else { - // if the full attachment is cached, we might as well fetch it anyways. - // no need to stick with thumbnail only, since we don't do any networking - widget.event.isAttachmentCached().then((cached) { - if (cached) { - _requestFile(); - } - }); + width = (height * ratio).round(); } - super.initState(); - } - - Widget getErrorWidget(BuildContext context, [dynamic error]) { - final String filename = widget.event.content.containsKey('filename') - ? widget.event.content['filename'] - : widget.event.body; - return getPlaceholderWidget( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - OutlinedButton.icon( - style: OutlinedButton.styleFrom( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - primary: Theme.of(context).textTheme.bodyText1!.color, - ), - icon: const Icon(Icons.download_outlined), - onPressed: () => widget.event.saveFile(context), - label: Text( - filename, - overflow: TextOverflow.fade, - softWrap: false, - maxLines: 1, - ), - ), - const SizedBox(height: 8), - if (widget.event.sizeString != null) Text(widget.event.sizeString!), - const SizedBox(height: 8), - Text((error ?? _error).toString()), - ], - ), - ); - } - - Widget getPlaceholderWidget({Widget? child}) { - Widget? blurhash; - if (widget.event.infoMap['xyz.amorgan.blurhash'] is String) { - final ratio = - widget.event.infoMap['w'] is int && widget.event.infoMap['h'] is int - ? widget.event.infoMap['w'] / widget.event.infoMap['h'] - : 1.0; - var width = 32; - var height = 32; - if (ratio > 1.0) { - height = (width / ratio).round(); - } else { - width = (height * ratio).round(); - } - blurhash = BlurHash( - hash: widget.event.infoMap['xyz.amorgan.blurhash'], + return SizedBox( + width: this.width, + height: this.height, + child: BlurHash( + hash: blurHashString, decodingWidth: width, decodingHeight: height, - imageFit: widget.fit, - ); - } - return Stack( - children: [ - if (blurhash != null) blurhash, - Center( - child: - child ?? const CircularProgressIndicator.adaptive(strokeWidth: 2), - ), - ], + imageFit: fit, + ), ); } - // Build a memory file (e2ee) - Widget getMemoryWidget() { - final isOriginal = _file != null || - widget.event.attachmentOrThumbnailMxcUrl(getThumbnail: true) == - widget.event.attachmentMxcUrl; - final key = isOriginal - ? widget.event.attachmentMxcUrl.toString() - : widget.event.thumbnailMxcUrl.toString(); - final mimetype = getMimetype(!isOriginal); - if (_contentRenderers.containsKey(mimetype)) { - return _contentRenderers[mimetype]!.memory!(_displayFile!.bytes, key); - } else { - return Image.memory( - _displayFile!.bytes, - key: ValueKey(key), - fit: widget.fit, - errorBuilder: (context, error, stacktrace) { - if (widget.event.hasThumbnail && !_requestedThumbnailOnFailure) { - _requestedThumbnailOnFailure = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - setState(() { - _file = null; - _requestFile(getThumbnail: true); - }); - }); - return getPlaceholderWidget(); - } - return getErrorWidget(context, error); - }, - frameBuilder: frameBuilder, - ); - } - } - - // build a network file (plaintext) - Widget getNetworkWidget() { - // For network files we try to utilize server-side thumbnailing as much as possible. - // Thus, we do the following logic: - // - try to display our URL - // - on failure: Attempt to display the in-event thumbnail instead - // - on failrue / non-existance: Display button to download or view in-app - final mimetype = getMimetype(_requestedThumbnailOnFailure); - if (displayUrl == attachmentUrl && - _contentRenderers.containsKey(mimetype)) { - return _contentRenderers[mimetype]!.network!(displayUrl); - } else { - return CachedNetworkImage( - // as we change the url on-error we need a key so that the widget actually updates - key: ValueKey(displayUrl), - imageUrl: displayUrl!, - placeholder: (context, url) { - if (!widget.thumbnailOnly && - displayUrl != thumbnailUrl && - displayUrl == attachmentUrl) { - // we have to display the thumbnail while loading - return FutureBuilder( - future: (() async { - return await DefaultCacheManager() - .getFileFromCache(thumbnailUrl!) != - null; - })(), - builder: (BuildContext context, AsyncSnapshot snapshot) { - if (!snapshot.hasData) { - return getPlaceholderWidget(); - } - final effectiveUrl = snapshot.data == true - ? thumbnailUrl! - : thumbnailUrlNoAnimated!; - return CachedNetworkImage( - key: ValueKey(effectiveUrl), - imageUrl: effectiveUrl, - placeholder: (c, u) => getPlaceholderWidget(), - imageBuilder: (context, imageProvider) => Image( - image: imageProvider, - frameBuilder: frameBuilder, - fit: widget.fit, - ), - ); - }, - ); - } - return getPlaceholderWidget(); - }, - imageBuilder: (context, imageProvider) => Image( - image: imageProvider, - frameBuilder: frameBuilder, - fit: widget.fit, - ), - errorWidget: (context, url, error) { - if (widget.event.hasThumbnail && !_requestedThumbnailOnFailure) { - // the image failed to load but the event has a thumbnail attached....so we can - // try to load this one! - _requestedThumbnailOnFailure = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - setState(() { - thumbnailUrl = widget.event - .getAttachmentUrl( - getThumbnail: true, - useThumbnailMxcUrl: true, - animated: widget.animated) - ?.toString(); - thumbnailUrlNoAnimated = widget.event - .getAttachmentUrl( - getThumbnail: true, - useThumbnailMxcUrl: true, - animated: false) - ?.toString(); - attachmentUrl = widget.event - .getAttachmentUrl( - useThumbnailMxcUrl: true, animated: widget.animated) - ?.toString(); - }); - }); - return getPlaceholderWidget(); - } else if (widget.thumbnailOnly && - displayUrl != attachmentUrl && - _knownMimetypes.contains(mimetype)) { - // Okay, the thumbnail failed to load, but we do know how to render the image - // ourselves. Let's offer the user a button to view it. - return getPlaceholderWidget( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - OutlinedButton( - style: OutlinedButton.styleFrom( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - primary: Theme.of(context).textTheme.bodyText1!.color, - ), - onPressed: () => onTap(context), - child: Text( - L10n.of(context)!.tapToShowImage, - overflow: TextOverflow.fade, - softWrap: false, - maxLines: 1, - ), - ), - if (widget.event.sizeString != null) ...[ - const SizedBox(height: 8), - Text(widget.event.sizeString!), - ] - ], - )); - } - return getErrorWidget(context, error); - }, - ); + void _onTap(BuildContext context) { + if (onTap != null) { + onTap!(); + return; } + if (!tapToView) return; + showDialog( + context: Matrix.of(context).navigatorContext, + useRootNavigator: false, + builder: (_) => ImageViewer(event), + ); } @override Widget build(BuildContext context) { - Widget content; - String key; - if (_error != null) { - content = getErrorWidget(context); - key = 'error'; - } else if (_displayFile != null) { - content = getMemoryWidget(); - key = 'memory-' + (content.key as ValueKey).value; - } else if (displayUrl != null) { - content = getNetworkWidget(); - key = 'network-' + (content.key as ValueKey).value; - } else { - content = getPlaceholderWidget(); - key = 'placeholder'; - } - if (widget.maxSize) { - content = AspectRatio( - aspectRatio: widget.width / widget.height, - child: content, - ); - } return InkWell( - onTap: () => onTap(context), + onTap: () => _onTap(context), child: Hero( - tag: widget.event.eventId, + tag: event.eventId, child: AnimatedSwitcher( duration: const Duration(milliseconds: 1000), child: Container( - key: ValueKey(key), - constraints: widget.maxSize - ? BoxConstraints.loose(Size( - widget.width, - widget.height, - )) + constraints: maxSize + ? BoxConstraints( + maxWidth: width, + maxHeight: height, + ) : null, - child: content, + child: MxcImage( + event: event, + width: width, + height: height, + fit: fit, + animated: animated, + isThumbnail: thumbnailOnly, + placeholder: _buildPlaceholder, + ), ), ), ), ); } - - void onTap(BuildContext context) { - if (widget.onTap != null) { - widget.onTap!(); - return; - } - if (!widget.tapToView) return; - showDialog( - context: Matrix.of(context).navigatorContext, - useRootNavigator: false, - builder: (_) => ImageViewer(widget.event, onLoaded: () { - // If the original file didn't load yet, we want to do that now. - // This is so that the original file displays after going on the image viewer, - // waiting for it to load, and then hitting back. This ensures that we always - // display the best image available, with requiring as little network as possible - if (_file == null) { - widget.event.isAttachmentCached().then((cached) { - if (cached) { - _requestFile(); - } - }); - } - }), - ); - } -} - -class _ImageBubbleContentRenderer { - final Widget Function(Uint8List, String)? memory; - final Widget Function(String?)? network; - - _ImageBubbleContentRenderer({this.memory, this.network}); } diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart index 3fa3a9059..fa5266361 100644 --- a/lib/pages/chat/events/message.dart +++ b/lib/pages/chat/events/message.dart @@ -100,10 +100,11 @@ class Message extends StatelessWidget { bottomRight: const Radius.circular(AppConfig.borderRadius), ); final noBubble = { - MessageTypes.Video, - MessageTypes.Image, - MessageTypes.Sticker, - }.contains(event.messageType); + MessageTypes.Video, + MessageTypes.Image, + MessageTypes.Sticker, + }.contains(event.messageType) && + !event.redacted; if (ownMessage) { color = displayEvent.status.isError diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index 59e4d6499..20fb76b96 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -78,6 +78,7 @@ class MessageContent extends StatelessWidget { fit: BoxFit.cover, ); case MessageTypes.Sticker: + if (event.redacted) continue textmessage; return Sticker(event); case MessageTypes.Audio: if (PlatformInfos.isMobile || PlatformInfos.isMacOS) { diff --git a/lib/pages/chat/events/message_reactions.dart b/lib/pages/chat/events/message_reactions.dart index c68c8b4a7..6f8c206c1 100644 --- a/lib/pages/chat/events/message_reactions.dart +++ b/lib/pages/chat/events/message_reactions.dart @@ -1,7 +1,6 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:collection/collection.dart' show IterableExtension; import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:matrix/matrix.dart'; @@ -10,6 +9,7 @@ import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/widgets/mxc_image.dart'; class MessageReactions extends StatelessWidget { final Event event; @@ -112,17 +112,12 @@ class _Reaction extends StatelessWidget { final fontSize = DefaultTextStyle.of(context).style.fontSize; Widget content; if (reactionKey!.startsWith('mxc://')) { - final src = Uri.parse(reactionKey!).getThumbnail( - Matrix.of(context).client, - width: 9999, - height: fontSize! * MediaQuery.of(context).devicePixelRatio, - method: ThumbnailMethod.scale, - ); content = Row( mainAxisSize: MainAxisSize.min, children: [ - CachedNetworkImage( - imageUrl: src.toString(), + MxcImage( + uri: Uri.parse(reactionKey!), + width: 9999, height: fontSize, ), const SizedBox(width: 4), diff --git a/lib/pages/chat/input_bar.dart b/lib/pages/chat/input_bar.dart index 8d8dcddb7..4e39268d3 100644 --- a/lib/pages/chat/input_bar.dart +++ b/lib/pages/chat/input_bar.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:emojis/emoji.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; @@ -10,6 +9,7 @@ import 'package:slugify/slugify.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/widgets/mxc_image.dart'; import '../../widgets/avatar.dart'; import '../../widgets/matrix.dart'; import 'command_hints.dart'; @@ -251,21 +251,15 @@ class InputBar extends StatelessWidget { ); } if (suggestion['type'] == 'emote') { - final ratio = MediaQuery.of(context).devicePixelRatio; - final url = Uri.parse(suggestion['mxc'] ?? '').getThumbnail( - room.client, - width: size * ratio, - height: size * ratio, - method: ThumbnailMethod.scale, - animated: true, - ); return Container( padding: padding, child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - CachedNetworkImage( - imageUrl: url.toString(), + MxcImage( + uri: suggestion['mxc'] is String + ? Uri.parse(suggestion['mxc'] ?? '') + : null, width: size, height: size, ), diff --git a/lib/pages/connect/sso_button.dart b/lib/pages/connect/sso_button.dart index 1a099da2e..dc15e493e 100644 --- a/lib/pages/connect/sso_button.dart +++ b/lib/pages/connect/sso_button.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; @@ -35,8 +34,8 @@ class SsoButton extends StatelessWidget { padding: const EdgeInsets.all(4.0), child: identityProvider.icon == null ? const Icon(Icons.web_outlined) - : CachedNetworkImage( - imageUrl: Uri.parse(identityProvider.icon!) + : Image.network( + Uri.parse(identityProvider.icon!) .getDownloadLink( Matrix.of(context).getLoginClient()) .toString(), diff --git a/lib/pages/image_viewer/image_viewer.dart b/lib/pages/image_viewer/image_viewer.dart index 241ec0649..fd9a3666b 100644 --- a/lib/pages/image_viewer/image_viewer.dart +++ b/lib/pages/image_viewer/image_viewer.dart @@ -10,9 +10,8 @@ import '../../utils/matrix_sdk_extensions.dart/event_extension.dart'; class ImageViewer extends StatefulWidget { final Event event; - final void Function()? onLoaded; - const ImageViewer(this.event, {Key? key, this.onLoaded}) : super(key: key); + const ImageViewer(this.event, {Key? key}) : super(key: key); @override ImageViewerController createState() => ImageViewerController(); diff --git a/lib/pages/image_viewer/image_viewer_view.dart b/lib/pages/image_viewer/image_viewer_view.dart index 94e29e9b8..ee90da167 100644 --- a/lib/pages/image_viewer/image_viewer_view.dart +++ b/lib/pages/image_viewer/image_viewer_view.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:fluffychat/pages/chat/events/image_bubble.dart'; import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/widgets/mxc_image.dart'; import 'image_viewer.dart'; class ImageViewerView extends StatelessWidget { @@ -53,15 +53,14 @@ class ImageViewerView extends StatelessWidget { maxScale: 10.0, onInteractionEnd: controller.onInteractionEnds, child: Center( - child: ImageBubble( - controller.widget.event, - tapToView: false, - onLoaded: controller.widget.onLoaded, - fit: BoxFit.contain, - backgroundColor: Colors.black, - maxSize: false, - thumbnailOnly: false, - animated: true, + child: Hero( + tag: controller.widget.event.eventId, + child: MxcImage( + event: controller.widget.event, + fit: BoxFit.contain, + isThumbnail: false, + animated: true, + ), ), ), ), diff --git a/lib/pages/settings_emotes/settings_emotes_view.dart b/lib/pages/settings_emotes/settings_emotes_view.dart index 9307faad6..43997fab9 100644 --- a/lib/pages/settings_emotes/settings_emotes_view.dart +++ b/lib/pages/settings_emotes/settings_emotes_view.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; +import 'package:fluffychat/widgets/mxc_image.dart'; import '../../widgets/matrix.dart'; import 'settings_emotes.dart'; @@ -216,15 +216,8 @@ class _EmoteImage extends StatelessWidget { @override Widget build(BuildContext context) { const size = 38.0; - final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; - final url = mxc.getThumbnail( - Matrix.of(context).client, - width: size * devicePixelRatio, - height: size * devicePixelRatio, - method: ThumbnailMethod.scale, - ); - return CachedNetworkImage( - imageUrl: url.toString(), + return MxcImage( + uri: mxc, fit: BoxFit.contain, width: size, height: size, diff --git a/lib/widgets/avatar.dart b/lib/widgets/avatar.dart index 708ea0161..e662591cb 100644 --- a/lib/widgets/avatar.dart +++ b/lib/widgets/avatar.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/utils/string_color.dart'; -import 'matrix.dart'; +import 'package:fluffychat/widgets/mxc_image.dart'; class Avatar extends StatelessWidget { final Uri? mxContent; @@ -27,11 +26,6 @@ class Avatar extends StatelessWidget { @override Widget build(BuildContext context) { - final src = mxContent?.getThumbnail( - client ?? Matrix.of(context).client, - width: size * MediaQuery.of(context).devicePixelRatio, - height: size * MediaQuery.of(context).devicePixelRatio, - ); var fallbackLetters = '@'; final name = this.name; if (name != null) { @@ -68,17 +62,12 @@ class Avatar extends StatelessWidget { noPic ? name?.lightColor : Theme.of(context).secondaryHeaderColor, child: noPic ? textWidget - : CachedNetworkImage( - imageUrl: src.toString(), + : MxcImage( + uri: mxContent, fit: BoxFit.cover, width: size, height: size, - placeholder: (c, s) => textWidget, - errorWidget: (c, s, d) => Stack( - children: [ - textWidget, - ], - ), + placeholder: (_) => textWidget, ), ), ), diff --git a/lib/widgets/content_banner.dart b/lib/widgets/content_banner.dart index d1a6b7179..3059357a2 100644 --- a/lib/widgets/content_banner.dart +++ b/lib/widgets/content_banner.dart @@ -1,17 +1,13 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:matrix/matrix.dart'; -import 'matrix.dart'; +import 'package:fluffychat/widgets/mxc_image.dart'; class ContentBanner extends StatelessWidget { final Uri? mxContent; final double height; final IconData defaultIcon; - final bool loading; final void Function()? onEdit; final Client? client; final double opacity; @@ -21,7 +17,6 @@ class ContentBanner extends StatelessWidget { {this.mxContent, this.height = 400, this.defaultIcon = Icons.account_circle_outlined, - this.loading = false, this.onEdit, this.client, this.opacity = 0.75, @@ -47,44 +42,27 @@ class ContentBanner extends StatelessWidget { bottom: 0, child: Opacity( opacity: opacity, - child: (!loading) - ? LayoutBuilder(builder: - (BuildContext context, BoxConstraints constraints) { - // #775 don't request new image resolution on every resize - // by rounding up to the next multiple of stepSize - const stepSize = 300; - final bannerSize = - constraints.maxWidth * window.devicePixelRatio; - final steppedBannerSize = - (bannerSize / stepSize).ceil() * stepSize; - final src = mxContent?.getThumbnail( - client ?? Matrix.of(context).client, - width: steppedBannerSize, - height: steppedBannerSize, - method: ThumbnailMethod.scale, - animated: true, - ); - return Hero( - tag: heroTag, - child: CachedNetworkImage( - imageUrl: src.toString(), - height: 300, - fit: BoxFit.cover, - errorWidget: (c, m, e) => Icon( - defaultIcon, - size: 200, - color: Theme.of(context) - .colorScheme - .onSecondaryContainer, - ), - ), - ); - }) - : Icon( - defaultIcon, - size: 200, - color: Theme.of(context).colorScheme.onSecondaryContainer, + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Hero( + tag: heroTag, + child: MxcImage( + uri: mxContent, + animated: true, + fit: BoxFit.cover, + height: 400, + width: 800, + placeholder: (c) => Center( + child: Icon( + defaultIcon, + size: 200, + color: + Theme.of(context).colorScheme.onSecondaryContainer, + ), ), + ), + ); + }), ), ), if (onEdit != null) diff --git a/lib/widgets/mxc_image.dart b/lib/widgets/mxc_image.dart new file mode 100644 index 000000000..25817e6cc --- /dev/null +++ b/lib/widgets/mxc_image.dart @@ -0,0 +1,147 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import 'package:http/http.dart' as http; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/utils/matrix_sdk_extensions.dart/matrix_file_extension.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class MxcImage extends StatefulWidget { + final Uri? uri; + final Event? event; + final double? width; + final double? height; + final BoxFit? fit; + final bool isThumbnail; + final bool animated; + final Duration retryDuration; + final Duration animationDuration; + final Curve animationCurve; + final ThumbnailMethod thumbnailMethod; + final Widget Function(BuildContext context)? placeholder; + + const MxcImage({ + this.uri, + this.event, + this.width, + this.height, + this.fit, + this.placeholder, + this.isThumbnail = true, + this.animated = false, + this.animationDuration = const Duration(milliseconds: 200), + this.retryDuration = const Duration(seconds: 2), + this.animationCurve = Curves.linear, + this.thumbnailMethod = ThumbnailMethod.scale, + Key? key, + }) : super(key: key); + + @override + State createState() => _MxcImageState(); +} + +class _MxcImageState extends State { + Uint8List? _imageData; + bool? _isCached; + + Future _load() async { + final client = Matrix.of(context).client; + final uri = widget.uri; + final event = widget.event; + + if (uri != null) { + final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; + final width = widget.width; + final realWidth = width == null ? null : width * devicePixelRatio; + final height = widget.height; + final realHeight = height == null ? null : height * devicePixelRatio; + + final httpUri = widget.isThumbnail + ? uri.getThumbnail( + client, + width: realWidth, + height: realHeight, + animated: widget.animated, + method: widget.thumbnailMethod, + ) + : uri.getDownloadLink(client); + + final storeKey = widget.isThumbnail ? httpUri : uri; + + if (_isCached == null) { + final cachedData = await client.database?.getFile(storeKey); + if (cachedData != null) { + if (!mounted) return; + setState(() { + _imageData = cachedData; + _isCached = true; + }); + return; + } + _isCached = false; + } + + final remoteData = await http.get(httpUri).then((r) => r.bodyBytes); + + if (!mounted) return; + setState(() { + _imageData = remoteData; + }); + await client.database?.storeFile(storeKey, remoteData, 0); + } + + if (event != null) { + final data = await event.downloadAndDecryptAttachment( + getThumbnail: widget.isThumbnail, + ); + if (data.detectFileType is MatrixImageFile) { + if (!mounted) return; + setState(() { + _imageData = data.bytes; + }); + return; + } + } + } + + void _tryLoad(_) async { + try { + await _load(); + } catch (_) { + if (!mounted) return; + await Future.delayed(widget.retryDuration); + _tryLoad(_); + } + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback(_tryLoad); + } + + @override + Widget build(BuildContext context) { + final data = _imageData; + + return AnimatedCrossFade( + duration: widget.animationDuration, + crossFadeState: + data == null ? CrossFadeState.showFirst : CrossFadeState.showSecond, + firstChild: widget.placeholder?.call(context) ?? + const Center( + child: CircularProgressIndicator.adaptive(), + ), + secondChild: data == null + ? Container() + : Image.memory( + data, + width: widget.width, + height: widget.height, + fit: widget.fit, + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index d301b0901..97816db22 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -107,7 +107,7 @@ packages: source: hosted version: "2.1.0" cached_network_image: - dependency: "direct main" + dependency: transitive description: name: cached_network_image url: "https://pub.dartlang.org" diff --git a/pubspec.yaml b/pubspec.yaml index a10314add..9fbc7b2f0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,7 +12,6 @@ dependencies: animations: ^2.0.2 async: ^2.8.2 blurhash_dart: ^1.1.0 - cached_network_image: ^3.2.0 callkeep: ^0.3.2 chewie: ^1.2.2 collection: ^1.15.0-nullsafety.4