Skip to content

Commit d3479af

Browse files
committed
settings [nfc]: Centralize logic for checking device animation settings
1 parent 1f92dfd commit d3479af

File tree

10 files changed

+98
-42
lines changed

10 files changed

+98
-42
lines changed

lib/model/settings.dart

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'package:flutter/widgets.dart';
12
import 'package:flutter/foundation.dart';
23

34
import '../generated/l10n/zulip_localizations.dart';
@@ -473,3 +474,54 @@ class GlobalSettingsStore extends ChangeNotifier {
473474
notifyListeners();
474475
}
475476
}
477+
478+
/// Whether to show an animated image in its still or animated version.
479+
///
480+
/// Use [resolve] to evaluate this for the given [BuildContext],
481+
/// which reads device-setting data for [animateConditionally].
482+
///
483+
/// Callers should try to check whether an image is animated,
484+
/// i.e. whether it has separate still and animated versions.
485+
/// This can't be done perfectly (in 2025-10)
486+
/// because of animated emoji that were uploaded before Zulip Server 5
487+
/// and don't have `still_url` filled in:
488+
/// https://github.com/zulip/zulip/issues/36339 .
489+
// TODO(server-future) Remove mention of still_url once all supported servers
490+
// have a fix for zulip/zulip#36339,
491+
// i.e. that have run a migration to fill in still_url.
492+
enum ImageAnimationMode {
493+
/// Always show the animated version, ignoring device settings.
494+
animateAlways,
495+
496+
/// Always show the still version, ignoring device settings.
497+
animateNever,
498+
499+
/// Show the animated version
500+
/// just if animations aren't disabled in device settings.
501+
animateConditionally,
502+
;
503+
504+
/// True if the image should be animated, false if it should be still.
505+
bool resolve(BuildContext context) {
506+
switch (this) {
507+
case animateAlways: return true;
508+
case animateNever: return false;
509+
case animateConditionally:
510+
// From reading code, this doesn't actually get set on iOS:
511+
// https://github.com/zulip/zulip-flutter/pull/410#discussion_r1408522293
512+
if (MediaQuery.disableAnimationsOf(context)) return false;
513+
514+
if (
515+
defaultTargetPlatform == TargetPlatform.iOS
516+
// TODO(#1924) On iOS 17+ (new in 2023), there's a more closely
517+
// relevant setting than "reduce motion". It's called "auto-play
518+
// animated images"; we should use that once Flutter exposes it.
519+
&& WidgetsBinding.instance.platformDispatcher.accessibilityFeatures.reduceMotion
520+
) {
521+
return false;
522+
}
523+
524+
return true;
525+
}
526+
}
527+
}

lib/widgets/emoji.dart

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart';
33

44
import '../api/model/model.dart';
55
import '../model/emoji.dart';
6+
import '../model/settings.dart';
67
import 'content.dart';
78

89
/// A widget showing an emoji.
@@ -13,7 +14,7 @@ class EmojiWidget extends StatelessWidget {
1314
required this.squareDimension,
1415
this.squareDimensionScaler = TextScaler.noScaling,
1516
this.imagePlaceholderStyle = EmojiImagePlaceholderStyle.square,
16-
this.neverAnimateImage = false,
17+
this.imageAnimationMode = ImageAnimationMode.animateConditionally,
1718
this.buildCustomTextEmoji,
1819
});
1920

@@ -35,11 +36,12 @@ class EmojiWidget extends StatelessWidget {
3536

3637
final EmojiImagePlaceholderStyle imagePlaceholderStyle;
3738

38-
/// Whether to show an animated emoji in its still (non-animated) variant
39-
/// only, even if device settings permit animation.
39+
/// Whether to show an animated emoji in its still or animated version.
4040
///
41-
/// Defaults to false.
42-
final bool neverAnimateImage;
41+
/// Ignored except for animated image emoji.
42+
///
43+
/// Defaults to [ImageAnimationMode.animateConditionally].
44+
final ImageAnimationMode imageAnimationMode;
4345

4446
/// An optional callback to specify a custom plain-text emoji style.
4547
///
@@ -66,7 +68,7 @@ class EmojiWidget extends StatelessWidget {
6668
EmojiImagePlaceholderStyle.nothing => SizedBox.shrink(),
6769
EmojiImagePlaceholderStyle.text => _buildTextEmoji(),
6870
},
69-
neverAnimate: neverAnimateImage),
71+
animationMode: imageAnimationMode),
7072
UnicodeEmojiDisplay() => UnicodeEmojiWidget(
7173
emojiDisplay: emojiDisplay,
7274
size: squareDimension,
@@ -185,7 +187,7 @@ class ImageEmojiWidget extends StatelessWidget {
185187
required this.size,
186188
this.textScaler = TextScaler.noScaling,
187189
this.errorBuilder,
188-
this.neverAnimate = false,
190+
this.animationMode = ImageAnimationMode.animateConditionally,
189191
});
190192

191193
final ImageEmojiDisplay emojiDisplay;
@@ -202,30 +204,20 @@ class ImageEmojiWidget extends StatelessWidget {
202204

203205
final ImageErrorWidgetBuilder? errorBuilder;
204206

205-
/// Whether to show an animated emoji in its still (non-animated) variant
206-
/// only, even if device settings permit animation.
207+
/// Whether to show an animated emoji in its still or animated version.
208+
///
209+
/// Ignored for non-animated emoji.
207210
///
208-
/// Defaults to false.
209-
final bool neverAnimate;
211+
/// Defaults to [ImageAnimationMode.animateConditionally].
212+
final ImageAnimationMode animationMode;
210213

211214
@override
212215
Widget build(BuildContext context) {
213-
final doNotAnimate =
214-
neverAnimate
215-
// From reading code, this doesn't actually get set on iOS:
216-
// https://github.com/zulip/zulip-flutter/pull/410#discussion_r1408522293
217-
|| MediaQuery.disableAnimationsOf(context)
218-
|| (defaultTargetPlatform == TargetPlatform.iOS
219-
// TODO(#1924) On iOS 17+ (new in 2023), there's a more closely
220-
// relevant setting than "reduce motion". It's called "auto-play
221-
// animated images"; we should use that once Flutter exposes it.
222-
&& WidgetsBinding.instance.platformDispatcher.accessibilityFeatures.reduceMotion);
223-
224216
final size = textScaler.scale(this.size);
225217

226-
final resolvedUrl = doNotAnimate
227-
? (emojiDisplay.resolvedStillUrl ?? emojiDisplay.resolvedUrl)
228-
: emojiDisplay.resolvedUrl;
218+
final resolvedUrl = animationMode.resolve(context)
219+
? emojiDisplay.resolvedUrl
220+
: (emojiDisplay.resolvedStillUrl ?? emojiDisplay.resolvedUrl);
229221

230222
return RealmContentNetworkImage(
231223
width: size, height: size,

lib/widgets/profile.dart

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import '../model/binding.dart';
1111
import '../model/content.dart';
1212
import '../model/narrow.dart';
1313
import '../model/presence.dart';
14+
import '../model/settings.dart';
1415
import 'app_bar.dart';
1516
import 'button.dart';
1617
import 'content.dart';
@@ -87,7 +88,7 @@ class ProfilePage extends StatelessWidget {
8788
userId: userId,
8889
fontSize: nameStyle.fontSize!,
8990
textScaler: MediaQuery.textScalerOf(context),
90-
neverAnimate: false,
91+
animationMode: ImageAnimationMode.animateConditionally,
9192
),
9293
]),
9394
textAlign: TextAlign.center,
@@ -267,7 +268,7 @@ class _SetStatusButton extends StatelessWidget {
267268
fontSize: 16,
268269
textScaler: MediaQuery.textScalerOf(context),
269270
position: StatusEmojiPosition.before,
270-
neverAnimate: false,
271+
animationMode: ImageAnimationMode.animateConditionally,
271272
),
272273
userStatus.text == null
273274
? TextSpan(text: zulipLocalizations.noStatusText,

lib/widgets/set_status.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import '../api/route/users.dart';
77
import '../basic.dart';
88
import '../generated/l10n/zulip_localizations.dart';
99
import '../log.dart';
10+
import '../model/settings.dart';
1011
import 'app_bar.dart';
1112
import 'emoji_reaction.dart';
1213
import 'icons.dart';
@@ -214,7 +215,11 @@ class _SetStatusPageState extends State<SetStatusPage> {
214215
final emoji = change.emoji.or(oldStatus.emoji);
215216
return emoji == null
216217
? const Icon(ZulipIcons.smile, size: 24)
217-
: UserStatusEmoji(emoji: emoji, size: 24, neverAnimate: false);
218+
: UserStatusEmoji(
219+
emoji: emoji,
220+
size: 24,
221+
animationMode: ImageAnimationMode.animateConditionally,
222+
);
218223
}),
219224
Icon(ZulipIcons.chevron_down, size: 16),
220225
]),

lib/widgets/user.dart

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import '../api/model/model.dart';
44
import '../model/avatar_url.dart';
55
import '../model/binding.dart';
66
import '../model/presence.dart';
7+
import '../model/settings.dart';
78
import 'content.dart';
89
import 'emoji.dart';
910
import 'icons.dart';
@@ -297,24 +298,24 @@ class _PresenceCircleState extends State<PresenceCircle> with PerAccountStoreAwa
297298
/// widgets.
298299
/// When there is no status emoji to be shown, the padding will be omitted too.
299300
///
300-
/// Use [neverAnimate] to forcefully disable the animation for animated emojis.
301-
/// Defaults to true.
301+
/// Use [animationMode] to control whether an animated emoji is shown
302+
/// in its still or animated version.
302303
class UserStatusEmoji extends StatelessWidget {
303304
const UserStatusEmoji({
304305
super.key,
305306
this.userId,
306307
this.emoji,
307308
required this.size,
308309
this.padding = EdgeInsets.zero,
309-
this.neverAnimate = true,
310+
this.animationMode = ImageAnimationMode.animateNever,
310311
}) : assert((userId == null) != (emoji == null),
311312
'Only one of the userId or emoji should be provided.');
312313

313314
final int? userId;
314315
final StatusEmoji? emoji;
315316
final double size;
316317
final EdgeInsetsGeometry padding;
317-
final bool neverAnimate;
318+
final ImageAnimationMode animationMode;
318319

319320
static const double _spanPadding = 4;
320321

@@ -329,7 +330,7 @@ class UserStatusEmoji extends StatelessWidget {
329330
required double fontSize,
330331
required TextScaler textScaler,
331332
StatusEmojiPosition position = StatusEmojiPosition.after,
332-
bool neverAnimate = true,
333+
ImageAnimationMode animationMode = ImageAnimationMode.animateNever,
333334
}) {
334335
final (double paddingStart, double paddingEnd) = switch (position) {
335336
StatusEmojiPosition.before => (0, _spanPadding),
@@ -340,7 +341,7 @@ class UserStatusEmoji extends StatelessWidget {
340341
alignment: PlaceholderAlignment.middle,
341342
child: UserStatusEmoji(userId: userId, emoji: emoji, size: size,
342343
padding: EdgeInsetsDirectional.only(start: paddingStart, end: paddingEnd),
343-
neverAnimate: neverAnimate));
344+
animationMode: animationMode));
344345
}
345346

346347
@override
@@ -363,7 +364,7 @@ class UserStatusEmoji extends StatelessWidget {
363364
child: EmojiWidget(
364365
emojiDisplay: emojiDisplay,
365366
squareDimension: size,
366-
neverAnimateImage: neverAnimate,
367+
imageAnimationMode: animationMode,
367368
buildCustomTextEmoji: () =>
368369
// Invoked when an image emoji's URL didn't parse; see
369370
// EmojiStore.emojiDisplayFor. Don't show text, just an empty square.

test/widgets/autocomplete_test.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import 'package:zulip/model/compose.dart';
1212
import 'package:zulip/model/emoji.dart';
1313
import 'package:zulip/model/localizations.dart';
1414
import 'package:zulip/model/narrow.dart';
15+
import 'package:zulip/model/settings.dart';
1516
import 'package:zulip/model/store.dart';
1617
import 'package:zulip/model/typing_status.dart';
1718
import 'package:zulip/widgets/autocomplete.dart';
@@ -212,7 +213,7 @@ void main() {
212213
matching: find.byType(UserStatusEmoji));
213214
check(statusEmojiFinder).findsOne();
214215
check(tester.widget<UserStatusEmoji>(statusEmojiFinder)
215-
.neverAnimate).isTrue();
216+
.animationMode).equals(ImageAnimationMode.animateNever);
216217
check(find.ancestor(of: statusEmojiFinder,
217218
matching: find.byType(MentionAutocompleteItem))).findsOne();
218219
}

test/widgets/message_list_test.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import 'package:zulip/model/localizations.dart';
2323
import 'package:zulip/model/message.dart';
2424
import 'package:zulip/model/message_list.dart';
2525
import 'package:zulip/model/narrow.dart';
26+
import 'package:zulip/model/settings.dart';
2627
import 'package:zulip/model/store.dart';
2728
import 'package:zulip/model/typing_status.dart';
2829
import 'package:zulip/widgets/app_bar.dart';
@@ -1860,7 +1861,7 @@ void main() {
18601861
matching: find.byType(UserStatusEmoji));
18611862
check(statusEmojiFinder).findsOne();
18621863
check(tester.widget<UserStatusEmoji>(statusEmojiFinder)
1863-
.neverAnimate).isTrue();
1864+
.animationMode).equals(ImageAnimationMode.animateNever);
18641865
check(find.ancestor(of: statusEmojiFinder,
18651866
matching: find.byType(SenderRow))).findsOne();
18661867
}

test/widgets/new_dm_sheet_test.dart

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:flutter_checks/flutter_checks.dart';
44
import 'package:flutter_test/flutter_test.dart';
55
import 'package:zulip/api/model/model.dart';
66
import 'package:zulip/basic.dart';
7+
import 'package:zulip/model/settings.dart';
78
import 'package:zulip/model/store.dart';
89
import 'package:zulip/widgets/app_bar.dart';
910
import 'package:zulip/widgets/compose_box.dart';
@@ -344,7 +345,7 @@ void main() {
344345
final tileStatusEmojiFinder = find.descendant(of: findUserTile(user),
345346
matching: statusEmojiFinder);
346347
check(tester.widget<UserStatusEmoji>(tileStatusEmojiFinder)
347-
.neverAnimate).isTrue();
348+
.animationMode).equals(ImageAnimationMode.animateNever);
348349
check(tileStatusEmojiFinder).findsOne();
349350
}
350351

@@ -354,7 +355,7 @@ void main() {
354355
final chipStatusEmojiFinder = find.descendant(of: findUserChip(user),
355356
matching: statusEmojiFinder);
356357
check(tester.widget<UserStatusEmoji>(chipStatusEmojiFinder)
357-
.neverAnimate).isTrue();
358+
.animationMode).equals(ImageAnimationMode.animateNever);
358359
check(chipStatusEmojiFinder).findsOne();
359360
}
360361

test/widgets/profile_test.dart

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import 'package:zulip/api/model/model.dart';
1313
import 'package:zulip/basic.dart';
1414
import 'package:zulip/model/localizations.dart';
1515
import 'package:zulip/model/narrow.dart';
16+
import 'package:zulip/model/settings.dart';
1617
import 'package:zulip/model/store.dart';
1718
import 'package:zulip/widgets/button.dart';
1819
import 'package:zulip/widgets/content.dart';
@@ -469,7 +470,7 @@ void main() {
469470
matching: find.byType(UserStatusEmoji));
470471
check(statusEmojiFinder).findsOne();
471472
check(tester.widget<UserStatusEmoji>(statusEmojiFinder)
472-
.neverAnimate).isFalse();
473+
.animationMode).equals(ImageAnimationMode.animateConditionally);
473474
check(find.text('Busy')).findsOne();
474475
});
475476

@@ -495,7 +496,7 @@ void main() {
495496
check(statusButtonFinder).findsOne();
496497
check(statusEmojiFinder).findsOne();
497498
check(tester.widget<UserStatusEmoji>(statusEmojiFinder)
498-
.neverAnimate).isFalse();
499+
.animationMode).equals(ImageAnimationMode.animateConditionally);
499500
check(statusTextFinder).findsOne();
500501

501502
check(find.descendant(of: statusButtonFinder,

test/widgets/recent_dm_conversations_test.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'package:zulip/api/model/events.dart';
77
import 'package:zulip/api/model/model.dart';
88
import 'package:zulip/basic.dart';
99
import 'package:zulip/model/narrow.dart';
10+
import 'package:zulip/model/settings.dart';
1011
import 'package:zulip/model/store.dart';
1112
import 'package:zulip/widgets/home.dart';
1213
import 'package:zulip/widgets/icons.dart';
@@ -189,7 +190,7 @@ void main() {
189190
matching: find.byType(UserStatusEmoji));
190191
check(statusEmojiFinder).findsOne();
191192
check(tester.widget<UserStatusEmoji>(statusEmojiFinder)
192-
.neverAnimate).isTrue();
193+
.animationMode).equals(ImageAnimationMode.animateNever);
193194
check(find.ancestor(of: statusEmojiFinder,
194195
matching: find.byType(RecentDmConversationsItem))).findsOne();
195196
}

0 commit comments

Comments
 (0)