diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 60bc014a..a6ce66d8 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -197,7 +197,6 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, F9C8EE392B9AB471149C306E /* [CP] Embed Pods Frameworks */, - F2B5CFB67942A2498CBB8D31 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -342,23 +341,6 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - F2B5CFB67942A2498CBB8D31 /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; F9C8EE392B9AB471149C306E /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; diff --git a/lib/pages/chat/events/image_bubble.dart b/lib/pages/chat/events/image_bubble.dart index b12d1662..f5219b05 100644 --- a/lib/pages/chat/events/image_bubble.dart +++ b/lib/pages/chat/events/image_bubble.dart @@ -1,16 +1,13 @@ -import 'dart:typed_data'; - import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/image_viewer/image_viewer.dart'; -import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_file_extension.dart'; +import 'package:fluffychat/widgets/mxc_image.dart'; import '../../../widgets/blur_hash.dart'; -class ImageBubble extends StatefulWidget { +class ImageBubble extends StatelessWidget { final Event event; final bool tapToView; final BoxFit fit; @@ -18,11 +15,11 @@ class ImageBubble extends StatefulWidget { final Color? backgroundColor; final bool thumbnailOnly; final bool animated; - final double? width; - final double? height; + final double width; + final double height; final void Function()? onTap; final BorderRadius? borderRadius; - final Duration retryDuration; + const ImageBubble( this.event, { this.tapToView = true, @@ -35,102 +32,50 @@ class ImageBubble extends StatefulWidget { this.animated = false, this.onTap, this.borderRadius, - this.retryDuration = const Duration(seconds: 2), super.key, }); - @override - State createState() => _ImageBubbleState(); -} - -class _ImageBubbleState extends State { - Uint8List? _imageData; - - Future _load() async { - final data = await widget.event.downloadAndDecryptAttachment( - getThumbnail: widget.thumbnailOnly, - ); - if (data.detectFileType is MatrixImageFile) { - if (!mounted) return; - setState(() { - _imageData = data.bytes; - }); - return; - } - } - - void _tryLoad([_]) async { - if (_imageData != null) { - return; - } - try { - await _load(); - } catch (_) { - if (!mounted) return; - await Future.delayed(widget.retryDuration); - _tryLoad(); - } - } - - @override - void initState() { - super.initState(); - _tryLoad(); - } - Widget _buildPlaceholder(BuildContext context) { - final width = widget.width; - final height = widget.height; - if (width == null || height == null) { - return const Center( - child: CircularProgressIndicator.adaptive( - strokeWidth: 2, - ), - ); - } final String blurHashString = - widget.event.infoMap['xyz.amorgan.blurhash'] is String - ? widget.event.infoMap['xyz.amorgan.blurhash'] + event.infoMap['xyz.amorgan.blurhash'] is String + ? event.infoMap['xyz.amorgan.blurhash'] : 'LEHV6nWB2yk8pyo0adR*.7kCMdnj'; - return SizedBox( - width: widget.width, - height: widget.height, + width: width, + height: height, child: BlurHash( blurhash: blurHashString, width: width, height: height, - fit: widget.fit, + fit: fit, ), ); } void _onTap(BuildContext context) { - if (widget.onTap != null) { - widget.onTap!(); + if (onTap != null) { + onTap!(); return; } - if (!widget.tapToView) return; + if (!tapToView) return; showDialog( context: context, useRootNavigator: false, - builder: (_) => ImageViewer(widget.event), + builder: (_) => ImageViewer(event), ); } @override Widget build(BuildContext context) { final borderRadius = - widget.borderRadius ?? BorderRadius.circular(AppConfig.borderRadius); - final data = _imageData; - final hasData = data != null; + this.borderRadius ?? BorderRadius.circular(AppConfig.borderRadius); return Material( color: Colors.transparent, clipBehavior: Clip.hardEdge, shape: RoundedRectangleBorder( borderRadius: borderRadius, side: BorderSide( - color: widget.event.messageType == MessageTypes.Sticker + color: event.messageType == MessageTypes.Sticker ? Colors.transparent : Theme.of(context).dividerColor, ), @@ -139,31 +84,17 @@ class _ImageBubbleState extends State { onTap: () => _onTap(context), borderRadius: borderRadius, child: Hero( - tag: widget.event.eventId, - child: AnimatedCrossFade( - crossFadeState: - hasData ? CrossFadeState.showSecond : CrossFadeState.showFirst, - duration: FluffyThemes.animationDuration, - firstChild: _buildPlaceholder(context), - secondChild: hasData - ? Image.memory( - data, - width: widget.width, - height: widget.height, - fit: widget.fit, - filterQuality: widget.thumbnailOnly - ? FilterQuality.low - : FilterQuality.medium, - errorBuilder: (context, __, ___) { - _imageData = null; - WidgetsBinding.instance.addPostFrameCallback(_tryLoad); - return _buildPlaceholder(context); - }, - ) - : SizedBox( - width: widget.width, - height: widget.height, - ), + tag: event.eventId, + child: MxcImage( + event: event, + width: width, + height: height, + fit: fit, + animated: animated, + isThumbnail: thumbnailOnly, + placeholder: event.messageType == MessageTypes.Sticker + ? null + : _buildPlaceholder, ), ), ), diff --git a/lib/pages/image_viewer/image_viewer_view.dart b/lib/pages/image_viewer/image_viewer_view.dart index 2f4abe44..e4352864 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 { @@ -55,13 +55,14 @@ class ImageViewerView extends StatelessWidget { maxScale: 10.0, onInteractionEnd: controller.onInteractionEnds, child: Center( - child: ImageBubble( - controller.widget.event, - fit: BoxFit.contain, - animated: true, - thumbnailOnly: false, - width: null, - height: null, + child: Hero( + tag: controller.widget.event.eventId, + child: MxcImage( + event: controller.widget.event, + fit: BoxFit.contain, + isThumbnail: false, + animated: true, + ), ), ), ), diff --git a/lib/widgets/mxc_image.dart b/lib/widgets/mxc_image.dart index a3ae695e..9290156b 100644 --- a/lib/widgets/mxc_image.dart +++ b/lib/widgets/mxc_image.dart @@ -1,69 +1,192 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; -import 'package:cached_network_image/cached_network_image.dart'; +import 'package:http/http.dart' as http; import 'package:matrix/matrix.dart'; +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_file_extension.dart'; import 'package:fluffychat/widgets/matrix.dart'; -class MxcImage extends StatelessWidget { +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; final String? cacheKey; const MxcImage({ this.uri, + this.event, this.width, this.height, this.fit, this.placeholder, this.isThumbnail = true, this.animated = false, + this.animationDuration = FluffyThemes.animationDuration, + this.retryDuration = const Duration(seconds: 2), + this.animationCurve = FluffyThemes.animationCurve, this.thumbnailMethod = ThumbnailMethod.scale, this.cacheKey, super.key, }); @override - Widget build(BuildContext context) { - final uri = this.uri; - if (uri == null) { - return placeholder?.call(context) ?? const Placeholder(); + State createState() => _MxcImageState(); +} + +class _MxcImageState extends State { + static final Map _imageDataCache = {}; + Uint8List? _imageDataNoCache; + Uint8List? get _imageData { + final cacheKey = widget.cacheKey; + return cacheKey == null ? _imageDataNoCache : _imageDataCache[cacheKey]; + } + + set _imageData(Uint8List? data) { + if (data == null) return; + final cacheKey = widget.cacheKey; + cacheKey == null + ? _imageDataNoCache = data + : _imageDataCache[cacheKey] = data; + } + + 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 response = await http.get(httpUri); + if (response.statusCode != 200) { + if (response.statusCode == 404) { + return; + } + throw Exception(); + } + final remoteData = response.bodyBytes; + + if (!mounted) return; + setState(() { + _imageData = remoteData; + }); + await client.database?.storeFile(storeKey, remoteData, 0); } - final client = Matrix.of(context).client; + if (event != null) { + final data = await event.downloadAndDecryptAttachment( + getThumbnail: widget.isThumbnail, + ); + if (data.detectFileType is MatrixImageFile) { + if (!mounted) return; + setState(() { + _imageData = data.bytes; + }); + return; + } + } + } - final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; - final width = this.width; - final realWidth = width == null ? null : width * devicePixelRatio; - final height = this.height; - final realHeight = height == null ? null : height * devicePixelRatio; + void _tryLoad(_) async { + if (_imageData != null) { + return; + } + try { + await _load(); + } catch (_) { + if (!mounted) return; + await Future.delayed(widget.retryDuration); + _tryLoad(_); + } + } - final imageUrl = isThumbnail - ? uri.getThumbnail( - client, - width: realWidth, - height: realHeight, - animated: animated, - method: thumbnailMethod, - ) - : uri.getDownloadLink(client); + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback(_tryLoad); + } - return CachedNetworkImage( - imageUrl: imageUrl.toString(), - width: width, - height: height, - fit: fit, - cacheKey: cacheKey, - filterQuality: isThumbnail ? FilterQuality.low : FilterQuality.medium, - errorWidget: placeholder == null - ? null - : (context, __, ___) => placeholder!.call(context), + Widget placeholder(BuildContext context) => + widget.placeholder?.call(context) ?? + Container( + width: widget.width, + height: widget.height, + alignment: Alignment.center, + child: const CircularProgressIndicator.adaptive(strokeWidth: 2), + ); + + @override + Widget build(BuildContext context) { + final data = _imageData; + final hasData = data != null && data.isNotEmpty; + + return AnimatedCrossFade( + crossFadeState: + hasData ? CrossFadeState.showSecond : CrossFadeState.showFirst, + duration: FluffyThemes.animationDuration, + firstChild: placeholder(context), + secondChild: hasData + ? Image.memory( + data, + width: widget.width, + height: widget.height, + fit: widget.fit, + filterQuality: + widget.isThumbnail ? FilterQuality.low : FilterQuality.medium, + errorBuilder: (context, __, ___) { + _isCached = false; + _imageData = null; + WidgetsBinding.instance.addPostFrameCallback(_tryLoad); + return placeholder(context); + }, + ) + : SizedBox( + width: widget.width, + height: widget.height, + ), ); } } diff --git a/pubspec.lock b/pubspec.lock index 477b147a..d3c9cad7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -129,30 +129,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - cached_network_image: - dependency: "direct main" - description: - name: cached_network_image - sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f" - url: "https://pub.dev" - source: hosted - version: "3.3.1" - cached_network_image_platform_interface: - dependency: transitive - description: - name: cached_network_image_platform_interface - sha256: "9e90e78ae72caa874a323d78fa6301b3fb8fa7ea76a8f96dc5b5bf79f283bf2f" - url: "https://pub.dev" - source: hosted - version: "4.0.0" - cached_network_image_web: - dependency: transitive - description: - name: cached_network_image_web - sha256: "205d6a9f1862de34b93184f22b9d2d94586b2f05c581d546695e3d8f6a805cd7" - url: "https://pub.dev" - source: hosted - version: "1.2.0" callkeep: dependency: "direct main" description: @@ -1294,14 +1270,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" - octo_image: - dependency: transitive - description: - name: octo_image - sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d" - url: "https://pub.dev" - source: hosted - version: "2.0.0" olm: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e085683b..9f23b88e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,7 +14,6 @@ dependencies: async: ^2.11.0 badges: ^3.1.2 blurhash_dart: ^1.2.1 - cached_network_image: ^3.3.1 callkeep: ^0.3.2 chewie: ^1.8.1 collection: ^1.18.0