mirror of
https://gitlab.com/famedly/fluffychat.git
synced 2024-11-27 14:59:29 +01:00
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:
commit
d20c74c298
@ -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<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);
|
||||
}
|
||||
});
|
||||
Widget _buildPlaceholder(BuildContext context) {
|
||||
if (event.messageType == MessageTypes.Sticker) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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: <Widget>[
|
||||
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<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);
|
||||
},
|
||||
);
|
||||
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});
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -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: <Widget>[
|
||||
CachedNetworkImage(
|
||||
imageUrl: src.toString(),
|
||||
MxcImage(
|
||||
uri: Uri.parse(reactionKey!),
|
||||
width: 9999,
|
||||
height: fontSize,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
|
@ -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: <Widget>[
|
||||
CachedNetworkImage(
|
||||
imageUrl: url.toString(),
|
||||
MxcImage(
|
||||
uri: suggestion['mxc'] is String
|
||||
? Uri.parse(suggestion['mxc'] ?? '')
|
||||
: null,
|
||||
width: size,
|
||||
height: size,
|
||||
),
|
||||
|
@ -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(),
|
||||
|
@ -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();
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -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)
|
||||
|
147
lib/widgets/mxc_image.dart
Normal file
147
lib/widgets/mxc_image.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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"
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user