From 7dd90d185c69f793a64672e607a47d1b0315ebf1 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Thu, 11 Jul 2024 15:54:43 -0700 Subject: [PATCH] emoji_reaction: Add EmojiReactionTheme, including dark variant Related: #95 --- lib/widgets/emoji_reaction.dart | 128 +++++++++++++++++++++----- lib/widgets/theme.dart | 7 +- test/flutter_checks.dart | 5 + test/widgets/emoji_reaction_test.dart | 40 ++++++++ 4 files changed, 155 insertions(+), 25 deletions(-) diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index 24210af8c1..03f8b3921d 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -8,6 +8,104 @@ import 'content.dart'; import 'store.dart'; import 'text.dart'; +/// Emoji-reaction styles that differ between light and dark themes. +class EmojiReactionTheme extends ThemeExtension { + factory EmojiReactionTheme.light() { + return EmojiReactionTheme._( + bgSelected: Colors.white, + + // TODO shadow effect, following web, which uses `box-shadow: inset`: + // https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow#inset + // Needs Flutter support for something like that: + // https://github.com/flutter/flutter/issues/18636 + // https://github.com/flutter/flutter/issues/52999 + // Until then use a solid color; a much-lightened version of the shadow color. + // Also adapt by making [borderUnselected] more transparent, so we'll + // want to check that against web when implementing the shadow. + bgUnselected: const HSLColor.fromAHSL(0.08, 210, 0.50, 0.875).toColor(), + + borderSelected: Colors.black.withOpacity(0.45), + + // TODO see TODO on [bgUnselected] about shadow effect + borderUnselected: Colors.black.withOpacity(0.05), + + textSelected: const HSLColor.fromAHSL(1, 210, 0.20, 0.20).toColor(), + textUnselected: const HSLColor.fromAHSL(1, 210, 0.20, 0.25).toColor(), + ); + } + + factory EmojiReactionTheme.dark() { + return EmojiReactionTheme._( + bgSelected: Colors.black.withOpacity(0.8), + bgUnselected: Colors.black.withOpacity(0.3), + borderSelected: Colors.white.withOpacity(0.75), + borderUnselected: Colors.white.withOpacity(0.15), + textSelected: Colors.white.withOpacity(0.85), + textUnselected: Colors.white.withOpacity(0.75), + ); + } + + EmojiReactionTheme._({ + required this.bgSelected, + required this.bgUnselected, + required this.borderSelected, + required this.borderUnselected, + required this.textSelected, + required this.textUnselected, + }); + + /// The [EmojiReactionTheme] from the context's active theme. + /// + /// The [ThemeData] must include [EmojiReactionTheme] in [ThemeData.extensions]. + static EmojiReactionTheme of(BuildContext context) { + final theme = Theme.of(context); + final extension = theme.extension(); + assert(extension != null); + return extension!; + } + + final Color bgSelected; + final Color bgUnselected; + final Color borderSelected; + final Color borderUnselected; + final Color textSelected; + final Color textUnselected; + + @override + EmojiReactionTheme copyWith({ + Color? bgSelected, + Color? bgUnselected, + Color? borderSelected, + Color? borderUnselected, + Color? textSelected, + Color? textUnselected, + }) { + return EmojiReactionTheme._( + bgSelected: bgSelected ?? this.bgSelected, + bgUnselected: bgUnselected ?? this.bgUnselected, + borderSelected: borderSelected ?? this.borderSelected, + borderUnselected: borderUnselected ?? this.borderUnselected, + textSelected: textSelected ?? this.textSelected, + textUnselected: textUnselected ?? this.textUnselected, + ); + } + + @override + EmojiReactionTheme lerp(EmojiReactionTheme other, double t) { + if (identical(this, other)) { + return this; + } + return EmojiReactionTheme._( + bgSelected: Color.lerp(bgSelected, other.bgSelected, t)!, + bgUnselected: Color.lerp(bgUnselected, other.bgUnselected, t)!, + borderSelected: Color.lerp(borderSelected, other.borderSelected, t)!, + borderUnselected: Color.lerp(borderUnselected, other.borderUnselected, t)!, + textSelected: Color.lerp(textSelected, other.textSelected, t)!, + textUnselected: Color.lerp(textUnselected, other.textUnselected, t)!, + ); + } +} + class ReactionChipsList extends StatelessWidget { const ReactionChipsList({ super.key, @@ -32,24 +130,6 @@ class ReactionChipsList extends StatelessWidget { } } -final _textColorSelected = const HSLColor.fromAHSL(1, 210, 0.20, 0.20).toColor(); -final _textColorUnselected = const HSLColor.fromAHSL(1, 210, 0.20, 0.25).toColor(); - -const _backgroundColorSelected = Colors.white; -// TODO shadow effect, following web, which uses `box-shadow: inset`: -// https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow#inset -// Needs Flutter support for something like that: -// https://github.com/flutter/flutter/issues/18636 -// https://github.com/flutter/flutter/issues/52999 -// Until then use a solid color; a much-lightened version of the shadow color. -// Also adapt by making [_borderColorUnselected] more transparent, so we'll -// want to check that against web when implementing the shadow. -final _backgroundColorUnselected = const HSLColor.fromAHSL(0.08, 210, 0.50, 0.875).toColor(); - -final _borderColorSelected = Colors.black.withOpacity(0.45); -// TODO see TODO on [_backgroundColorUnselected] about shadow effect -final _borderColorUnselected = Colors.black.withOpacity(0.05); - class ReactionChip extends StatelessWidget { final bool showName; final int messageId; @@ -85,10 +165,11 @@ class ReactionChip extends StatelessWidget { }).join(', ') : userIds.length.toString(); - final borderColor = selfVoted ? _borderColorSelected : _borderColorUnselected; - final labelColor = selfVoted ? _textColorSelected : _textColorUnselected; - final backgroundColor = selfVoted ? _backgroundColorSelected : _backgroundColorUnselected; - final splashColor = selfVoted ? _backgroundColorUnselected : _backgroundColorSelected; + final reactionTheme = EmojiReactionTheme.of(context); + final borderColor = selfVoted ? reactionTheme.borderSelected : reactionTheme.borderUnselected; + final labelColor = selfVoted ? reactionTheme.textSelected : reactionTheme.textUnselected; + final backgroundColor = selfVoted ? reactionTheme.bgSelected : reactionTheme.bgUnselected; + final splashColor = selfVoted ? reactionTheme.bgUnselected : reactionTheme.bgSelected; final highlightColor = splashColor.withOpacity(0.5); final borderSide = BorderSide( @@ -349,6 +430,7 @@ class _TextEmoji extends StatelessWidget { @override Widget build(BuildContext context) { + final reactionTheme = EmojiReactionTheme.of(context); return Text( textAlign: TextAlign.end, textScaler: _textEmojiScalerClamped(context), @@ -356,7 +438,7 @@ class _TextEmoji extends StatelessWidget { style: TextStyle( fontSize: 14 * 0.8, height: 1, // to be denser when we have to wrap - color: selected ? _textColorSelected : _textColorUnselected, + color: selected ? reactionTheme.textSelected : reactionTheme.textUnselected, ).merge(weightVariableTextStyle(context, wght: selected ? 600 : null)), // Encourage line breaks before "_" (common in these), but try not diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index c8742cae72..24b56905e6 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../api/model/model.dart'; import 'content.dart'; +import 'emoji_reaction.dart'; import 'stream_colors.dart'; import 'text.dart'; @@ -37,11 +38,13 @@ ThemeData zulipThemeData(BuildContext context) { switch (brightness) { case Brightness.light: { designVariables = DesignVariables.light(); - themeExtensions = [ContentTheme.light(context), designVariables]; + themeExtensions = + [ContentTheme.light(context), designVariables, EmojiReactionTheme.light()]; } case Brightness.dark: { designVariables = DesignVariables.dark(); - themeExtensions = [ContentTheme.dark(context), designVariables]; + themeExtensions = + [ContentTheme.dark(context), designVariables, EmojiReactionTheme.dark()]; } } diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart index b3b6b88980..d0906d849b 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -126,3 +126,8 @@ extension MediaQueryDataChecks on Subject { Subject get textScaler => has((x) => x.textScaler, 'textScaler'); // TODO more } + +extension MaterialChecks on Subject { + Subject get color => has((x) => x.color, 'color'); + // TODO more +} diff --git a/test/widgets/emoji_reaction_test.dart b/test/widgets/emoji_reaction_test.dart index f8af9d0d7b..9a6d423190 100644 --- a/test/widgets/emoji_reaction_test.dart +++ b/test/widgets/emoji_reaction_test.dart @@ -11,6 +11,7 @@ import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/emoji_reaction.dart'; +import 'package:zulip/widgets/theme.dart'; import '../example_data.dart' as eg; import '../flutter_checks.dart'; @@ -214,6 +215,45 @@ void main() { } } } + + testWidgets('Smoke test for light/dark/lerped', (tester) async { + await prepare(); + await store.addUsers([eg.selfUser, eg.otherUser]); + + debugFollowPlatformBrightness = true; + tester.platformDispatcher.platformBrightnessTestValue = Brightness.light; + addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue); + + await setupChipsInBox(tester, reactions: [ + Reaction.fromJson({ + 'user_id': eg.selfUser.userId, + 'emoji_name': 'smile', 'emoji_code': '1f642', 'reaction_type': 'unicode_emoji'}), + Reaction.fromJson({ + 'user_id': eg.otherUser.userId, + 'emoji_name': 'tada', 'emoji_code': '1f389', 'reaction_type': 'unicode_emoji'}), + ]); + + Color? backgroundColor(String emojiName) { + final material = tester.widget(find.descendant( + of: find.byTooltip(emojiName), matching: find.byType(Material))); + return material.color; + } + + check(backgroundColor('smile')).equals(EmojiReactionTheme.light().bgSelected); + check(backgroundColor('tada')).equals(EmojiReactionTheme.light().bgUnselected); + + tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; + await tester.pump(); + + await tester.pump(kThemeAnimationDuration * 0.4); + final expectedLerped = EmojiReactionTheme.light().lerp(EmojiReactionTheme.dark(), 0.4); + check(backgroundColor('smile')).equals(expectedLerped.bgSelected); + check(backgroundColor('tada')).equals(expectedLerped.bgUnselected); + + await tester.pump(kThemeAnimationDuration * 0.6); + check(backgroundColor('smile')).equals(EmojiReactionTheme.dark().bgSelected); + check(backgroundColor('tada')).equals(EmojiReactionTheme.dark().bgUnselected); + }); }); // TODO more tests: