diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 24ca2a8919..da12823520 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -184,7 +184,17 @@ class RealmEmojiItem { final String emojiCode; final String name; final String sourceUrl; + + /// The non-animated version, if this is an animated emoji. + /// + /// As of 2025-10, this will be missing on animated emoji + /// that were uploaded before Zulip Server 5 when this was added; + /// see https://github.com/zulip/zulip/issues/36339 . + // TODO(server-future) Update dartdoc once all supported servers + // have a fix for https://github.com/zulip/zulip/issues/36339 + // i.e. that have run a migration to fill this in for animated emoji. final String? stillUrl; + final bool deactivated; final int? authorId; diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index aafa7c2971..85a573cdcb 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -7,7 +7,6 @@ import 'package:flutter/rendering.dart'; import 'package:html/dom.dart' as dom; import 'package:intl/intl.dart' as intl; -import '../api/core.dart'; import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../model/content.dart'; @@ -16,6 +15,7 @@ import 'actions.dart'; import 'code_block.dart'; import 'dialog.dart'; import 'icons.dart'; +import 'image.dart'; import 'inset_shadow.dart'; import 'katex.dart'; import 'lightbox.dart'; @@ -1447,111 +1447,6 @@ void _launchUrl(BuildContext context, String urlString) async { } } -/// Like [Image.network], but includes [authHeader] if [src] is on-realm. -/// -/// Use this to present image content in the ambient realm: avatars, images in -/// messages, etc. Must have a [PerAccountStoreWidget] ancestor. -/// -/// If [src] is an on-realm URL (it has the same origin as the ambient -/// [Auth.realmUrl]), then an HTTP request to fetch the image will include the -/// user's [authHeader]. -/// -/// If [src] is off-realm (e.g., a Gravatar URL), no auth header will be sent. -/// -/// The image will be cached according to the cache behavior of [Image.network], -/// which may mean the cache is shared between realms. -class RealmContentNetworkImage extends StatelessWidget { - const RealmContentNetworkImage( - this.src, { - super.key, - this.scale = 1.0, - this.frameBuilder, - this.loadingBuilder, - this.errorBuilder, - this.semanticLabel, - this.excludeFromSemantics = false, - this.width, - this.height, - this.color, - this.opacity, - this.colorBlendMode, - this.fit, - this.alignment = Alignment.center, - this.repeat = ImageRepeat.noRepeat, - this.centerSlice, - this.matchTextDirection = false, - this.gaplessPlayback = false, - this.filterQuality = FilterQuality.low, - this.isAntiAlias = false, - // `headers` skipped - this.cacheWidth, - this.cacheHeight, - }); - - final Uri src; - - final double scale; - final ImageFrameBuilder? frameBuilder; - final ImageLoadingBuilder? loadingBuilder; - final ImageErrorWidgetBuilder? errorBuilder; - final String? semanticLabel; - final bool excludeFromSemantics; - final double? width; - final double? height; - final Color? color; - final Animation? opacity; - final BlendMode? colorBlendMode; - final BoxFit? fit; - final AlignmentGeometry alignment; - final ImageRepeat repeat; - final Rect? centerSlice; - final bool matchTextDirection; - final bool gaplessPlayback; - final FilterQuality filterQuality; - final bool isAntiAlias; - // `headers` skipped - final int? cacheWidth; - final int? cacheHeight; - - @override - Widget build(BuildContext context) { - final account = PerAccountStoreWidget.of(context).account; - - return Image.network( - src.toString(), - - scale: scale, - frameBuilder: frameBuilder, - loadingBuilder: loadingBuilder, - errorBuilder: errorBuilder, - semanticLabel: semanticLabel, - excludeFromSemantics: excludeFromSemantics, - width: width, - height: height, - color: color, - opacity: opacity, - colorBlendMode: colorBlendMode, - fit: fit, - alignment: alignment, - repeat: repeat, - centerSlice: centerSlice, - matchTextDirection: matchTextDirection, - gaplessPlayback: gaplessPlayback, - filterQuality: filterQuality, - isAntiAlias: isAntiAlias, - headers: { - // Only send the auth header to the server `auth` belongs to. - if (src.origin == account.realmUrl.origin) ...authHeader( - email: account.email, apiKey: account.apiKey, - ), - ...userAgentHeader(), - }, - cacheWidth: cacheWidth, - cacheHeight: cacheHeight, - ); - } -} - // // Small helpers. // diff --git a/lib/widgets/emoji.dart b/lib/widgets/emoji.dart index 227083b25b..4bbc70925f 100644 --- a/lib/widgets/emoji.dart +++ b/lib/widgets/emoji.dart @@ -3,7 +3,7 @@ import 'package:flutter/widgets.dart'; import '../api/model/model.dart'; import '../model/emoji.dart'; -import 'content.dart'; +import 'image.dart'; /// A widget showing an emoji. class EmojiWidget extends StatelessWidget { @@ -13,7 +13,7 @@ class EmojiWidget extends StatelessWidget { required this.squareDimension, this.squareDimensionScaler = TextScaler.noScaling, this.imagePlaceholderStyle = EmojiImagePlaceholderStyle.square, - this.neverAnimateImage = false, + this.imageAnimationMode = ImageAnimationMode.animateConditionally, this.buildCustomTextEmoji, }); @@ -35,11 +35,12 @@ class EmojiWidget extends StatelessWidget { final EmojiImagePlaceholderStyle imagePlaceholderStyle; - /// Whether to show an animated emoji in its still (non-animated) variant - /// only, even if device settings permit animation. + /// Whether to show an animated emoji in its still or animated version. /// - /// Defaults to false. - final bool neverAnimateImage; + /// Ignored except for animated image emoji. + /// + /// Defaults to [ImageAnimationMode.animateConditionally]. + final ImageAnimationMode imageAnimationMode; /// An optional callback to specify a custom plain-text emoji style. /// @@ -66,7 +67,7 @@ class EmojiWidget extends StatelessWidget { EmojiImagePlaceholderStyle.nothing => SizedBox.shrink(), EmojiImagePlaceholderStyle.text => _buildTextEmoji(), }, - neverAnimate: neverAnimateImage), + animationMode: imageAnimationMode), UnicodeEmojiDisplay() => UnicodeEmojiWidget( emojiDisplay: emojiDisplay, size: squareDimension, @@ -185,7 +186,7 @@ class ImageEmojiWidget extends StatelessWidget { required this.size, this.textScaler = TextScaler.noScaling, this.errorBuilder, - this.neverAnimate = false, + this.animationMode = ImageAnimationMode.animateConditionally, }); final ImageEmojiDisplay emojiDisplay; @@ -202,30 +203,20 @@ class ImageEmojiWidget extends StatelessWidget { final ImageErrorWidgetBuilder? errorBuilder; - /// Whether to show an animated emoji in its still (non-animated) variant - /// only, even if device settings permit animation. + /// Whether to show an animated emoji in its still or animated version. + /// + /// Ignored for non-animated emoji. /// - /// Defaults to false. - final bool neverAnimate; + /// Defaults to [ImageAnimationMode.animateConditionally]. + final ImageAnimationMode animationMode; @override Widget build(BuildContext context) { - final doNotAnimate = - neverAnimate - // From reading code, this doesn't actually get set on iOS: - // https://github.com/zulip/zulip-flutter/pull/410#discussion_r1408522293 - || MediaQuery.disableAnimationsOf(context) - || (defaultTargetPlatform == TargetPlatform.iOS - // TODO(#1924) On iOS 17+ (new in 2023), there's a more closely - // relevant setting than "reduce motion". It's called "auto-play - // animated images"; we should use that once Flutter exposes it. - && WidgetsBinding.instance.platformDispatcher.accessibilityFeatures.reduceMotion); - final size = textScaler.scale(this.size); - final resolvedUrl = doNotAnimate - ? (emojiDisplay.resolvedStillUrl ?? emojiDisplay.resolvedUrl) - : emojiDisplay.resolvedUrl; + final resolvedUrl = animationMode.resolve(context) + ? emojiDisplay.resolvedUrl + : (emojiDisplay.resolvedStillUrl ?? emojiDisplay.resolvedUrl); return RealmContentNetworkImage( width: size, height: size, diff --git a/lib/widgets/image.dart b/lib/widgets/image.dart new file mode 100644 index 0000000000..9e416da449 --- /dev/null +++ b/lib/widgets/image.dart @@ -0,0 +1,151 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../api/core.dart'; +import 'store.dart'; + +/// Like [Image.network], but includes [authHeader] if [src] is on-realm. +/// +/// Use this to present image content in the ambient realm: avatars, images in +/// messages, etc. Must have a [PerAccountStoreWidget] ancestor. +/// +/// If [src] is an on-realm URL (it has the same origin as the ambient +/// [Auth.realmUrl]), then an HTTP request to fetch the image will include the +/// user's [authHeader]. +/// +/// If [src] is off-realm (e.g., a Gravatar URL), no auth header will be sent. +/// +/// The image will be cached according to the cache behavior of [Image.network], +/// which may mean the cache is shared between realms. +class RealmContentNetworkImage extends StatelessWidget { + const RealmContentNetworkImage( + this.src, { + super.key, + this.scale = 1.0, + this.frameBuilder, + this.loadingBuilder, + this.errorBuilder, + this.semanticLabel, + this.excludeFromSemantics = false, + this.width, + this.height, + this.color, + this.opacity, + this.colorBlendMode, + this.fit, + this.alignment = Alignment.center, + this.repeat = ImageRepeat.noRepeat, + this.centerSlice, + this.matchTextDirection = false, + this.gaplessPlayback = false, + this.filterQuality = FilterQuality.low, + this.isAntiAlias = false, + // `headers` skipped + this.cacheWidth, + this.cacheHeight, + }); + + final Uri src; + + final double scale; + final ImageFrameBuilder? frameBuilder; + final ImageLoadingBuilder? loadingBuilder; + final ImageErrorWidgetBuilder? errorBuilder; + final String? semanticLabel; + final bool excludeFromSemantics; + final double? width; + final double? height; + final Color? color; + final Animation? opacity; + final BlendMode? colorBlendMode; + final BoxFit? fit; + final AlignmentGeometry alignment; + final ImageRepeat repeat; + final Rect? centerSlice; + final bool matchTextDirection; + final bool gaplessPlayback; + final FilterQuality filterQuality; + final bool isAntiAlias; + // `headers` skipped + final int? cacheWidth; + final int? cacheHeight; + + @override + Widget build(BuildContext context) { + final account = PerAccountStoreWidget.of(context).account; + + return Image.network( + src.toString(), + + scale: scale, + frameBuilder: frameBuilder, + loadingBuilder: loadingBuilder, + errorBuilder: errorBuilder, + semanticLabel: semanticLabel, + excludeFromSemantics: excludeFromSemantics, + width: width, + height: height, + color: color, + opacity: opacity, + colorBlendMode: colorBlendMode, + fit: fit, + alignment: alignment, + repeat: repeat, + centerSlice: centerSlice, + matchTextDirection: matchTextDirection, + gaplessPlayback: gaplessPlayback, + filterQuality: filterQuality, + isAntiAlias: isAntiAlias, + headers: { + // Only send the auth header to the server `auth` belongs to. + if (src.origin == account.realmUrl.origin) ...authHeader( + email: account.email, apiKey: account.apiKey, + ), + ...userAgentHeader(), + }, + cacheWidth: cacheWidth, + cacheHeight: cacheHeight, + ); + } +} + +/// Whether to show an animated image in its still or animated version. +/// +/// Use [resolve] to evaluate this for the given [BuildContext], +/// which reads device-setting data for [animateConditionally]. +enum ImageAnimationMode { + /// Always show the animated version. + animateAlways, + + /// Always show the still version. + animateNever, + + /// Show the animated version + /// just if animations aren't disabled in device settings. + animateConditionally, + ; + + /// True if the image should be animated, false if it should be still. + bool resolve(BuildContext context) { + switch (this) { + case animateAlways: return true; + case animateNever: return false; + case animateConditionally: + // From reading code, this doesn't actually get set on iOS: + // https://github.com/zulip/zulip-flutter/pull/410#discussion_r1408522293 + if (MediaQuery.disableAnimationsOf(context)) return false; + + if ( + defaultTargetPlatform == TargetPlatform.iOS + // TODO(#1924) On iOS 17+ (new in 2023), there's a more closely + // relevant setting than "reduce motion". It's called "auto-play + // animated images"; we should use that once Flutter exposes it. + && WidgetsBinding.instance.platformDispatcher.accessibilityFeatures.reduceMotion + ) { + return false; + } + + return true; + } + } +} diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index cc8bd5f295..45e2b4b7f4 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -11,6 +11,7 @@ import '../model/binding.dart'; import 'actions.dart'; import 'content.dart'; import 'dialog.dart'; +import 'image.dart'; import 'message_list.dart'; import 'page.dart'; import 'store.dart'; diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index 42521e63bf..35edf889ef 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -15,6 +15,7 @@ import 'app_bar.dart'; import 'button.dart'; import 'content.dart'; import 'icons.dart'; +import 'image.dart'; import 'message_list.dart'; import 'page.dart'; import 'remote_settings.dart'; @@ -87,7 +88,7 @@ class ProfilePage extends StatelessWidget { userId: userId, fontSize: nameStyle.fontSize!, textScaler: MediaQuery.textScalerOf(context), - neverAnimate: false, + animationMode: ImageAnimationMode.animateConditionally, ), ]), textAlign: TextAlign.center, @@ -267,7 +268,7 @@ class _SetStatusButton extends StatelessWidget { fontSize: 16, textScaler: MediaQuery.textScalerOf(context), position: StatusEmojiPosition.before, - neverAnimate: false, + animationMode: ImageAnimationMode.animateConditionally, ), userStatus.text == null ? TextSpan(text: zulipLocalizations.noStatusText, diff --git a/lib/widgets/set_status.dart b/lib/widgets/set_status.dart index 4e13c13cab..92ef933a9c 100644 --- a/lib/widgets/set_status.dart +++ b/lib/widgets/set_status.dart @@ -10,6 +10,7 @@ import '../log.dart'; import 'app_bar.dart'; import 'emoji_reaction.dart'; import 'icons.dart'; +import 'image.dart'; import 'inset_shadow.dart'; import 'page.dart'; import 'store.dart'; @@ -214,7 +215,11 @@ class _SetStatusPageState extends State { final emoji = change.emoji.or(oldStatus.emoji); return emoji == null ? const Icon(ZulipIcons.smile, size: 24) - : UserStatusEmoji(emoji: emoji, size: 24, neverAnimate: false); + : UserStatusEmoji( + emoji: emoji, + size: 24, + animationMode: ImageAnimationMode.animateConditionally, + ); }), Icon(ZulipIcons.chevron_down, size: 16), ]), diff --git a/lib/widgets/user.dart b/lib/widgets/user.dart index 2459379d57..358f0ff108 100644 --- a/lib/widgets/user.dart +++ b/lib/widgets/user.dart @@ -4,9 +4,9 @@ import '../api/model/model.dart'; import '../model/avatar_url.dart'; import '../model/binding.dart'; import '../model/presence.dart'; -import 'content.dart'; import 'emoji.dart'; import 'icons.dart'; +import 'image.dart'; import 'store.dart'; import 'theme.dart'; @@ -297,8 +297,8 @@ class _PresenceCircleState extends State with PerAccountStoreAwa /// widgets. /// When there is no status emoji to be shown, the padding will be omitted too. /// -/// Use [neverAnimate] to forcefully disable the animation for animated emojis. -/// Defaults to true. +/// Use [animationMode] to control whether an animated emoji is shown +/// in its still or animated version. class UserStatusEmoji extends StatelessWidget { const UserStatusEmoji({ super.key, @@ -306,7 +306,7 @@ class UserStatusEmoji extends StatelessWidget { this.emoji, required this.size, this.padding = EdgeInsets.zero, - this.neverAnimate = true, + this.animationMode = ImageAnimationMode.animateNever, }) : assert((userId == null) != (emoji == null), 'Only one of the userId or emoji should be provided.'); @@ -314,7 +314,7 @@ class UserStatusEmoji extends StatelessWidget { final StatusEmoji? emoji; final double size; final EdgeInsetsGeometry padding; - final bool neverAnimate; + final ImageAnimationMode animationMode; static const double _spanPadding = 4; @@ -329,7 +329,7 @@ class UserStatusEmoji extends StatelessWidget { required double fontSize, required TextScaler textScaler, StatusEmojiPosition position = StatusEmojiPosition.after, - bool neverAnimate = true, + ImageAnimationMode animationMode = ImageAnimationMode.animateNever, }) { final (double paddingStart, double paddingEnd) = switch (position) { StatusEmojiPosition.before => (0, _spanPadding), @@ -340,7 +340,7 @@ class UserStatusEmoji extends StatelessWidget { alignment: PlaceholderAlignment.middle, child: UserStatusEmoji(userId: userId, emoji: emoji, size: size, padding: EdgeInsetsDirectional.only(start: paddingStart, end: paddingEnd), - neverAnimate: neverAnimate)); + animationMode: animationMode)); } @override @@ -363,7 +363,7 @@ class UserStatusEmoji extends StatelessWidget { child: EmojiWidget( emojiDisplay: emojiDisplay, squareDimension: size, - neverAnimateImage: neverAnimate, + imageAnimationMode: animationMode, buildCustomTextEmoji: () => // Invoked when an image emoji's URL didn't parse; see // EmojiStore.emojiDisplayFor. Don't show text, just an empty square. diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index ddcfc26036..982c6ee3f0 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -16,6 +16,7 @@ import 'package:zulip/model/store.dart'; import 'package:zulip/model/typing_status.dart'; import 'package:zulip/widgets/autocomplete.dart'; import 'package:zulip/widgets/compose_box.dart'; +import 'package:zulip/widgets/image.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/user.dart'; @@ -212,7 +213,7 @@ void main() { matching: find.byType(UserStatusEmoji)); check(statusEmojiFinder).findsOne(); check(tester.widget(statusEmojiFinder) - .neverAnimate).isTrue(); + .animationMode).equals(ImageAnimationMode.animateNever); check(find.ancestor(of: statusEmojiFinder, matching: find.byType(MentionAutocompleteItem))).findsOne(); } diff --git a/test/widgets/checks.dart b/test/widgets/checks.dart index 90b640bd8e..d3a2761ad1 100644 --- a/test/widgets/checks.dart +++ b/test/widgets/checks.dart @@ -9,9 +9,9 @@ import 'package:zulip/widgets/all_channels.dart'; import 'package:zulip/widgets/button.dart'; import 'package:zulip/widgets/channel_colors.dart'; import 'package:zulip/widgets/compose_box.dart'; -import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/emoji.dart'; import 'package:zulip/widgets/emoji_reaction.dart'; +import 'package:zulip/widgets/image.dart'; import 'package:zulip/widgets/login.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 3241c65e75..92fe17796d 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -6,7 +6,6 @@ import 'package:flutter/rendering.dart'; import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:zulip/api/core.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/messages.dart'; @@ -16,10 +15,10 @@ import 'package:zulip/model/settings.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/image.dart'; import 'package:zulip/widgets/katex.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; -import 'package:zulip/widgets/store.dart'; import 'package:zulip/widgets/text.dart'; import '../api/fake_api.dart'; @@ -1320,46 +1319,6 @@ void main() { }); }); - group('RealmContentNetworkImage', () { - final authHeaders = authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey); - - Future>> actualHeaders(WidgetTester tester, Uri src) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - - final httpClient = prepareBoringImageHttpClient(); - - await tester.pumpWidget(GlobalStoreWidget( - child: PerAccountStoreWidget(accountId: eg.selfAccount.id, - child: RealmContentNetworkImage(src)))); - await tester.pump(); - await tester.pump(); - - return httpClient.request.headers.values; - } - - testWidgets('includes auth header if `src` on-realm', (tester) async { - check(await actualHeaders(tester, Uri.parse('https://chat.example/image.png'))) - .deepEquals({ - 'Authorization': [authHeaders['Authorization']!], - 'User-Agent': [userAgentHeader()['User-Agent']!], - }); - debugNetworkImageHttpClientProvider = null; - }); - - testWidgets('excludes auth header if `src` off-realm', (tester) async { - check(await actualHeaders(tester, Uri.parse('https://other.example/image.png'))) - .deepEquals({'User-Agent': [userAgentHeader()['User-Agent']!]}); - debugNetworkImageHttpClientProvider = null; - }); - - testWidgets('throws if no `PerAccountStoreWidget` ancestor', (tester) async { - await tester.pumpWidget( - RealmContentNetworkImage(Uri.parse('https://zulip.invalid/path/to/image.png'), filterQuality: FilterQuality.medium)); - check(tester.takeException()).isA(); - }); - }); - group('MessageTable', () { testFontWeight('bold column header label', // | a | b | c | d | diff --git a/test/widgets/image_test.dart b/test/widgets/image_test.dart new file mode 100644 index 0000000000..706793b951 --- /dev/null +++ b/test/widgets/image_test.dart @@ -0,0 +1,54 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/core.dart'; +import 'package:zulip/widgets/image.dart'; +import 'package:zulip/widgets/store.dart'; + +import '../example_data.dart' as eg; +import '../model/binding.dart'; +import '../test_images.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + group('RealmContentNetworkImage', () { + final authHeaders = authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey); + + Future>> actualHeaders(WidgetTester tester, Uri src) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + + final httpClient = prepareBoringImageHttpClient(); + + await tester.pumpWidget(GlobalStoreWidget( + child: PerAccountStoreWidget(accountId: eg.selfAccount.id, + child: RealmContentNetworkImage(src)))); + await tester.pump(); + await tester.pump(); + + return httpClient.request.headers.values; + } + + testWidgets('includes auth header if `src` on-realm', (tester) async { + check(await actualHeaders(tester, Uri.parse('https://chat.example/image.png'))) + .deepEquals({ + 'Authorization': [authHeaders['Authorization']!], + 'User-Agent': [userAgentHeader()['User-Agent']!], + }); + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('excludes auth header if `src` off-realm', (tester) async { + check(await actualHeaders(tester, Uri.parse('https://other.example/image.png'))) + .deepEquals({'User-Agent': [userAgentHeader()['User-Agent']!]}); + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('throws if no `PerAccountStoreWidget` ancestor', (tester) async { + await tester.pumpWidget( + RealmContentNetworkImage(Uri.parse('https://zulip.invalid/path/to/image.png'), filterQuality: FilterQuality.medium)); + check(tester.takeException()).isA(); + }); + }); +} diff --git a/test/widgets/lightbox_test.dart b/test/widgets/lightbox_test.dart index 296cf48baa..f76810d377 100644 --- a/test/widgets/lightbox_test.dart +++ b/test/widgets/lightbox_test.dart @@ -14,7 +14,7 @@ import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/app.dart'; -import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/image.dart'; import 'package:zulip/widgets/lightbox.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/user.dart'; diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 240285a222..89b2a31bd8 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -31,6 +31,7 @@ import 'package:zulip/widgets/color.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/image.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/store.dart'; @@ -1869,7 +1870,7 @@ void main() { matching: find.byType(UserStatusEmoji)); check(statusEmojiFinder).findsOne(); check(tester.widget(statusEmojiFinder) - .neverAnimate).isTrue(); + .animationMode).equals(ImageAnimationMode.animateNever); check(find.ancestor(of: statusEmojiFinder, matching: find.byType(SenderRow))).findsOne(); } diff --git a/test/widgets/new_dm_sheet_test.dart b/test/widgets/new_dm_sheet_test.dart index aff2e8ba6e..b5cf9d8301 100644 --- a/test/widgets/new_dm_sheet_test.dart +++ b/test/widgets/new_dm_sheet_test.dart @@ -9,6 +9,7 @@ import 'package:zulip/widgets/app_bar.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/image.dart'; import 'package:zulip/widgets/new_dm_sheet.dart'; import 'package:zulip/widgets/store.dart'; import 'package:zulip/widgets/user.dart'; @@ -344,7 +345,7 @@ void main() { final tileStatusEmojiFinder = find.descendant(of: findUserTile(user), matching: statusEmojiFinder); check(tester.widget(tileStatusEmojiFinder) - .neverAnimate).isTrue(); + .animationMode).equals(ImageAnimationMode.animateNever); check(tileStatusEmojiFinder).findsOne(); } @@ -354,7 +355,7 @@ void main() { final chipStatusEmojiFinder = find.descendant(of: findUserChip(user), matching: statusEmojiFinder); check(tester.widget(chipStatusEmojiFinder) - .neverAnimate).isTrue(); + .animationMode).equals(ImageAnimationMode.animateNever); check(chipStatusEmojiFinder).findsOne(); } diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart index c50a02563e..c213619095 100644 --- a/test/widgets/profile_test.dart +++ b/test/widgets/profile_test.dart @@ -15,8 +15,8 @@ import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/button.dart'; -import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/image.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/remote_settings.dart'; @@ -469,7 +469,7 @@ void main() { matching: find.byType(UserStatusEmoji)); check(statusEmojiFinder).findsOne(); check(tester.widget(statusEmojiFinder) - .neverAnimate).isFalse(); + .animationMode).equals(ImageAnimationMode.animateConditionally); check(find.text('Busy')).findsOne(); }); @@ -495,7 +495,7 @@ void main() { check(statusButtonFinder).findsOne(); check(statusEmojiFinder).findsOne(); check(tester.widget(statusEmojiFinder) - .neverAnimate).isFalse(); + .animationMode).equals(ImageAnimationMode.animateConditionally); check(statusTextFinder).findsOne(); check(find.descendant(of: statusButtonFinder, diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index 85ff96d6ea..a558734119 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -10,6 +10,7 @@ import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/home.dart'; import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/image.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/new_dm_sheet.dart'; import 'package:zulip/widgets/page.dart'; @@ -189,7 +190,7 @@ void main() { matching: find.byType(UserStatusEmoji)); check(statusEmojiFinder).findsOne(); check(tester.widget(statusEmojiFinder) - .neverAnimate).isTrue(); + .animationMode).equals(ImageAnimationMode.animateNever); check(find.ancestor(of: statusEmojiFinder, matching: find.byType(RecentDmConversationsItem))).findsOne(); } diff --git a/test/widgets/user_test.dart b/test/widgets/user_test.dart index 5078da0497..f84196a488 100644 --- a/test/widgets/user_test.dart +++ b/test/widgets/user_test.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/model/store.dart'; -import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/image.dart'; import 'package:zulip/widgets/store.dart'; import 'package:zulip/widgets/user.dart';