Merge branch 'krille/simplify-mxc-content' into 'main'

refactor: Simplify MxcImage and replace CachedNetworkImage

See merge request famedly/fluffychat!967
This commit is contained in:
Krille Fear 2022-07-29 17:09:42 +00:00
commit d20c74c298
14 changed files with 255 additions and 538 deletions

View File

@ -1,20 +1,13 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_blurhash/flutter_blurhash.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:matrix/matrix.dart';
import 'package:fluffychat/pages/image_viewer/image_viewer.dart'; import 'package:fluffychat/pages/image_viewer/image_viewer.dart';
import 'package:fluffychat/widgets/matrix.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 Event event;
final bool tapToView; final bool tapToView;
final BoxFit fit; final BoxFit fit;
@ -24,7 +17,6 @@ class ImageBubble extends StatefulWidget {
final bool animated; final bool animated;
final double width; final double width;
final double height; final double height;
final void Function()? onLoaded;
final void Function()? onTap; final void Function()? onTap;
const ImageBubble( const ImageBubble(
@ -34,7 +26,6 @@ class ImageBubble extends StatefulWidget {
this.backgroundColor, this.backgroundColor,
this.fit = BoxFit.cover, this.fit = BoxFit.cover,
this.thumbnailOnly = true, this.thumbnailOnly = true,
this.onLoaded,
this.width = 400, this.width = 400,
this.height = 300, this.height = 300,
this.animated = false, this.animated = false,
@ -42,446 +33,78 @@ class ImageBubble extends StatefulWidget {
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@override Widget _buildPlaceholder(BuildContext context) {
_ImageBubbleState createState() => _ImageBubbleState(); if (event.messageType == MessageTypes.Sticker) {
} return const Center(
child: CircularProgressIndicator.adaptive(),
class _ImageBubbleState extends State<ImageBubble> { );
// 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 = <String>{
'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, _ImageBubbleContentRenderer>{};
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<void> _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);
}
});
} }
} final String blurHashString =
event.infoMap['xyz.amorgan.blurhash'] is String
Widget frameBuilder(_, Widget child, int? frame, __) { ? event.infoMap['xyz.amorgan.blurhash']
// as servers might return animated gifs as thumbnails and we want them to *not* play : 'LEHV6nWB2yk8pyo0adR*.7kCMdnj';
// animated, we'll have to store the first frame in a variable and display that instead final ratio = event.infoMap['w'] is int && event.infoMap['h'] is int
if (widget.animated) { ? event.infoMap['w'] / event.infoMap['h']
return child; : 1.0;
} var width = 32;
if (frame == 0) { var height = 32;
_firstFrame = child; if (ratio > 1.0) {
} height = (width / ratio).round();
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);
}
if (!widget.thumbnailOnly && attachmentUrl == null) {
_requestFile();
} else { } else {
// if the full attachment is cached, we might as well fetch it anyways. width = (height * ratio).round();
// no need to stick with thumbnail only, since we don't do any networking
widget.event.isAttachmentCached().then((cached) {
if (cached) {
_requestFile();
}
});
} }
super.initState(); return SizedBox(
} width: this.width,
height: this.height,
Widget getErrorWidget(BuildContext context, [dynamic error]) { child: BlurHash(
final String filename = widget.event.content.containsKey('filename') hash: blurHashString,
? 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'],
decodingWidth: width, decodingWidth: width,
decodingHeight: height, decodingHeight: height,
imageFit: widget.fit, imageFit: fit,
); ),
}
return Stack(
children: <Widget>[
if (blurhash != null) blurhash,
Center(
child:
child ?? const CircularProgressIndicator.adaptive(strokeWidth: 2),
),
],
); );
} }
// Build a memory file (e2ee) void _onTap(BuildContext context) {
Widget getMemoryWidget() { if (onTap != null) {
final isOriginal = _file != null || onTap!();
widget.event.attachmentOrThumbnailMxcUrl(getThumbnail: true) == return;
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<bool>(
future: (() async {
return await DefaultCacheManager()
.getFileFromCache(thumbnailUrl!) !=
null;
})(),
builder: (BuildContext context, AsyncSnapshot<bool> 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);
},
);
} }
if (!tapToView) return;
showDialog(
context: Matrix.of(context).navigatorContext,
useRootNavigator: false,
builder: (_) => ImageViewer(event),
);
} }
@override @override
Widget build(BuildContext context) { 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( return InkWell(
onTap: () => onTap(context), onTap: () => _onTap(context),
child: Hero( child: Hero(
tag: widget.event.eventId, tag: event.eventId,
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: const Duration(milliseconds: 1000), duration: const Duration(milliseconds: 1000),
child: Container( child: Container(
key: ValueKey(key), constraints: maxSize
constraints: widget.maxSize ? BoxConstraints(
? BoxConstraints.loose(Size( maxWidth: width,
widget.width, maxHeight: height,
widget.height, )
))
: null, : 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});
} }

View File

@ -100,10 +100,11 @@ class Message extends StatelessWidget {
bottomRight: const Radius.circular(AppConfig.borderRadius), bottomRight: const Radius.circular(AppConfig.borderRadius),
); );
final noBubble = { final noBubble = {
MessageTypes.Video, MessageTypes.Video,
MessageTypes.Image, MessageTypes.Image,
MessageTypes.Sticker, MessageTypes.Sticker,
}.contains(event.messageType); }.contains(event.messageType) &&
!event.redacted;
if (ownMessage) { if (ownMessage) {
color = displayEvent.status.isError color = displayEvent.status.isError

View File

@ -78,6 +78,7 @@ class MessageContent extends StatelessWidget {
fit: BoxFit.cover, fit: BoxFit.cover,
); );
case MessageTypes.Sticker: case MessageTypes.Sticker:
if (event.redacted) continue textmessage;
return Sticker(event); return Sticker(event);
case MessageTypes.Audio: case MessageTypes.Audio:
if (PlatformInfos.isMobile || PlatformInfos.isMacOS) { if (PlatformInfos.isMobile || PlatformInfos.isMacOS) {

View File

@ -1,7 +1,6 @@
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:collection/collection.dart' show IterableExtension; import 'package:collection/collection.dart' show IterableExtension;
import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:matrix/matrix.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/utils/platform_infos.dart';
import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
class MessageReactions extends StatelessWidget { class MessageReactions extends StatelessWidget {
final Event event; final Event event;
@ -112,17 +112,12 @@ class _Reaction extends StatelessWidget {
final fontSize = DefaultTextStyle.of(context).style.fontSize; final fontSize = DefaultTextStyle.of(context).style.fontSize;
Widget content; Widget content;
if (reactionKey!.startsWith('mxc://')) { 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( content = Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
CachedNetworkImage( MxcImage(
imageUrl: src.toString(), uri: Uri.parse(reactionKey!),
width: 9999,
height: fontSize, height: fontSize,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),

View File

@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:emojis/emoji.dart'; import 'package:emojis/emoji.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_typeahead/flutter_typeahead.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/config/app_config.dart';
import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
import '../../widgets/avatar.dart'; import '../../widgets/avatar.dart';
import '../../widgets/matrix.dart'; import '../../widgets/matrix.dart';
import 'command_hints.dart'; import 'command_hints.dart';
@ -251,21 +251,15 @@ class InputBar extends StatelessWidget {
); );
} }
if (suggestion['type'] == 'emote') { 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( return Container(
padding: padding, padding: padding,
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[ children: <Widget>[
CachedNetworkImage( MxcImage(
imageUrl: url.toString(), uri: suggestion['mxc'] is String
? Uri.parse(suggestion['mxc'] ?? '')
: null,
width: size, width: size,
height: size, height: size,
), ),

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
@ -35,8 +34,8 @@ class SsoButton extends StatelessWidget {
padding: const EdgeInsets.all(4.0), padding: const EdgeInsets.all(4.0),
child: identityProvider.icon == null child: identityProvider.icon == null
? const Icon(Icons.web_outlined) ? const Icon(Icons.web_outlined)
: CachedNetworkImage( : Image.network(
imageUrl: Uri.parse(identityProvider.icon!) Uri.parse(identityProvider.icon!)
.getDownloadLink( .getDownloadLink(
Matrix.of(context).getLoginClient()) Matrix.of(context).getLoginClient())
.toString(), .toString(),

View File

@ -10,9 +10,8 @@ import '../../utils/matrix_sdk_extensions.dart/event_extension.dart';
class ImageViewer extends StatefulWidget { class ImageViewer extends StatefulWidget {
final Event event; 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 @override
ImageViewerController createState() => ImageViewerController(); ImageViewerController createState() => ImageViewerController();

View File

@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.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/utils/platform_infos.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
import 'image_viewer.dart'; import 'image_viewer.dart';
class ImageViewerView extends StatelessWidget { class ImageViewerView extends StatelessWidget {
@ -53,15 +53,14 @@ class ImageViewerView extends StatelessWidget {
maxScale: 10.0, maxScale: 10.0,
onInteractionEnd: controller.onInteractionEnds, onInteractionEnd: controller.onInteractionEnds,
child: Center( child: Center(
child: ImageBubble( child: Hero(
controller.widget.event, tag: controller.widget.event.eventId,
tapToView: false, child: MxcImage(
onLoaded: controller.widget.onLoaded, event: controller.widget.event,
fit: BoxFit.contain, fit: BoxFit.contain,
backgroundColor: Colors.black, isThumbnail: false,
maxSize: false, animated: true,
thumbnailOnly: false, ),
animated: true,
), ),
), ),
), ),

View File

@ -1,12 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
import '../../widgets/matrix.dart'; import '../../widgets/matrix.dart';
import 'settings_emotes.dart'; import 'settings_emotes.dart';
@ -216,15 +216,8 @@ class _EmoteImage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const size = 38.0; const size = 38.0;
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; return MxcImage(
final url = mxc.getThumbnail( uri: mxc,
Matrix.of(context).client,
width: size * devicePixelRatio,
height: size * devicePixelRatio,
method: ThumbnailMethod.scale,
);
return CachedNetworkImage(
imageUrl: url.toString(),
fit: BoxFit.contain, fit: BoxFit.contain,
width: size, width: size,
height: size, height: size,

View File

@ -1,10 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:fluffychat/utils/string_color.dart'; import 'package:fluffychat/utils/string_color.dart';
import 'matrix.dart'; import 'package:fluffychat/widgets/mxc_image.dart';
class Avatar extends StatelessWidget { class Avatar extends StatelessWidget {
final Uri? mxContent; final Uri? mxContent;
@ -27,11 +26,6 @@ class Avatar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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 = '@'; var fallbackLetters = '@';
final name = this.name; final name = this.name;
if (name != null) { if (name != null) {
@ -68,17 +62,12 @@ class Avatar extends StatelessWidget {
noPic ? name?.lightColor : Theme.of(context).secondaryHeaderColor, noPic ? name?.lightColor : Theme.of(context).secondaryHeaderColor,
child: noPic child: noPic
? textWidget ? textWidget
: CachedNetworkImage( : MxcImage(
imageUrl: src.toString(), uri: mxContent,
fit: BoxFit.cover, fit: BoxFit.cover,
width: size, width: size,
height: size, height: size,
placeholder: (c, s) => textWidget, placeholder: (_) => textWidget,
errorWidget: (c, s, d) => Stack(
children: [
textWidget,
],
),
), ),
), ),
), ),

View File

@ -1,17 +1,13 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'matrix.dart'; import 'package:fluffychat/widgets/mxc_image.dart';
class ContentBanner extends StatelessWidget { class ContentBanner extends StatelessWidget {
final Uri? mxContent; final Uri? mxContent;
final double height; final double height;
final IconData defaultIcon; final IconData defaultIcon;
final bool loading;
final void Function()? onEdit; final void Function()? onEdit;
final Client? client; final Client? client;
final double opacity; final double opacity;
@ -21,7 +17,6 @@ class ContentBanner extends StatelessWidget {
{this.mxContent, {this.mxContent,
this.height = 400, this.height = 400,
this.defaultIcon = Icons.account_circle_outlined, this.defaultIcon = Icons.account_circle_outlined,
this.loading = false,
this.onEdit, this.onEdit,
this.client, this.client,
this.opacity = 0.75, this.opacity = 0.75,
@ -47,44 +42,27 @@ class ContentBanner extends StatelessWidget {
bottom: 0, bottom: 0,
child: Opacity( child: Opacity(
opacity: opacity, opacity: opacity,
child: (!loading) child: LayoutBuilder(
? LayoutBuilder(builder: builder: (BuildContext context, BoxConstraints constraints) {
(BuildContext context, BoxConstraints constraints) { return Hero(
// #775 don't request new image resolution on every resize tag: heroTag,
// by rounding up to the next multiple of stepSize child: MxcImage(
const stepSize = 300; uri: mxContent,
final bannerSize = animated: true,
constraints.maxWidth * window.devicePixelRatio; fit: BoxFit.cover,
final steppedBannerSize = height: 400,
(bannerSize / stepSize).ceil() * stepSize; width: 800,
final src = mxContent?.getThumbnail( placeholder: (c) => Center(
client ?? Matrix.of(context).client, child: Icon(
width: steppedBannerSize, defaultIcon,
height: steppedBannerSize, size: 200,
method: ThumbnailMethod.scale, color:
animated: true, Theme.of(context).colorScheme.onSecondaryContainer,
); ),
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,
), ),
),
);
}),
), ),
), ),
if (onEdit != null) if (onEdit != null)

147
lib/widgets/mxc_image.dart Normal file
View File

@ -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<MxcImage> createState() => _MxcImageState();
}
class _MxcImageState extends State<MxcImage> {
Uint8List? _imageData;
bool? _isCached;
Future<void> _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,
),
);
}
}

View File

@ -107,7 +107,7 @@ packages:
source: hosted source: hosted
version: "2.1.0" version: "2.1.0"
cached_network_image: cached_network_image:
dependency: "direct main" dependency: transitive
description: description:
name: cached_network_image name: cached_network_image
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"

View File

@ -12,7 +12,6 @@ dependencies:
animations: ^2.0.2 animations: ^2.0.2
async: ^2.8.2 async: ^2.8.2
blurhash_dart: ^1.1.0 blurhash_dart: ^1.1.0
cached_network_image: ^3.2.0
callkeep: ^0.3.2 callkeep: ^0.3.2
chewie: ^1.2.2 chewie: ^1.2.2
collection: ^1.15.0-nullsafety.4 collection: ^1.15.0-nullsafety.4