|
| 1 | +import 'dart:ui'; |
| 2 | + |
| 3 | +import 'package:flutter/material.dart'; |
| 4 | + |
| 5 | +import '../api/model/initial_snapshot.dart'; |
| 6 | +import '../api/model/model.dart'; |
| 7 | +import '../api/route/messages.dart'; |
| 8 | +import '../model/content.dart'; |
| 9 | +import 'content.dart'; |
| 10 | +import 'store.dart'; |
| 11 | +import 'text.dart'; |
| 12 | + |
| 13 | +class ReactionChipsList extends StatelessWidget { |
| 14 | + final int messageId; |
| 15 | + final Reactions reactions; |
| 16 | + |
| 17 | + const ReactionChipsList({ |
| 18 | + super.key, |
| 19 | + required this.messageId, |
| 20 | + required this.reactions, |
| 21 | + }); |
| 22 | + |
| 23 | + @override |
| 24 | + Widget build(BuildContext context) { |
| 25 | + final store = PerAccountStoreWidget.of(context); |
| 26 | + final displayEmojiReactionUsers = store.userSettings?.displayEmojiReactionUsers ?? false; |
| 27 | + final showNames = displayEmojiReactionUsers && reactions.total <= 3; |
| 28 | + |
| 29 | + return Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, |
| 30 | + children: reactions.aggregated.map((reactionVotes) => ReactionChip( |
| 31 | + showName: showNames, |
| 32 | + messageId: messageId, reactionWithVotes: reactionVotes), |
| 33 | + ).toList()); |
| 34 | + } |
| 35 | +} |
| 36 | + |
| 37 | +final _textColorSelected = const HSLColor.fromAHSL(1, 210, 0.20, 0.20).toColor(); |
| 38 | +final _textColorUnselected = const HSLColor.fromAHSL(1, 210, 0.20, 0.25).toColor(); |
| 39 | + |
| 40 | +const _backgroundColorSelected = Colors.white; |
| 41 | +// TODO shadow effect, following web, which uses `box-shadow: inset`: |
| 42 | +// https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow#inset |
| 43 | +// Needs Flutter support for something like that: |
| 44 | +// https://github.com/flutter/flutter/issues/18636 |
| 45 | +// https://github.com/flutter/flutter/issues/52999 |
| 46 | +// Until then use a solid color; a much-lightened version of the shadow color. |
| 47 | +// Also adapt by making [_borderColorUnselected] more transparent, so we'll |
| 48 | +// want to check that against web when implementing the shadow. |
| 49 | +final _backgroundColorUnselected = const HSLColor.fromAHSL(0.15, 210, 0.50, 0.875).toColor(); |
| 50 | + |
| 51 | +final _borderColorSelected = Colors.black.withOpacity(0.40); |
| 52 | +// TODO see TODO on [_backgroundColorUnselected] about shadow effect |
| 53 | +final _borderColorUnselected = Colors.black.withOpacity(0.06); |
| 54 | + |
| 55 | +class ReactionChip extends StatelessWidget { |
| 56 | + final bool showName; |
| 57 | + final int messageId; |
| 58 | + final ReactionWithVotes reactionWithVotes; |
| 59 | + |
| 60 | + const ReactionChip({ |
| 61 | + super.key, |
| 62 | + required this.showName, |
| 63 | + required this.messageId, |
| 64 | + required this.reactionWithVotes, |
| 65 | + }); |
| 66 | + |
| 67 | + @override |
| 68 | + Widget build(BuildContext context) { |
| 69 | + final store = PerAccountStoreWidget.of(context); |
| 70 | + |
| 71 | + final reactionType = reactionWithVotes.reactionType; |
| 72 | + final emojiCode = reactionWithVotes.emojiCode; |
| 73 | + final emojiName = reactionWithVotes.emojiName; |
| 74 | + final userIds = reactionWithVotes.userIds; |
| 75 | + |
| 76 | + final emojiset = store.userSettings?.emojiset ?? Emojiset.google; |
| 77 | + |
| 78 | + final selfUserId = store.account.userId; |
| 79 | + final selfVoted = userIds.contains(selfUserId); |
| 80 | + final label = showName |
| 81 | + ? userIds.map((id) { |
| 82 | + return id == selfUserId |
| 83 | + ? 'You' |
| 84 | + : store.users[id]?.fullName ?? '(unknown user)'; |
| 85 | + }).join(', ') |
| 86 | + : userIds.length.toString(); |
| 87 | + |
| 88 | + final borderColor = selfVoted ? _borderColorSelected : _borderColorUnselected; |
| 89 | + final labelColor = selfVoted ? _textColorSelected : _textColorUnselected; |
| 90 | + final backgroundColor = selfVoted ? _backgroundColorSelected : _backgroundColorUnselected; |
| 91 | + final splashColor = selfVoted ? _backgroundColorUnselected : _backgroundColorSelected; |
| 92 | + final highlightColor = splashColor.withOpacity(0.5); |
| 93 | + |
| 94 | + final borderSide = BorderSide(color: borderColor, width: 1); |
| 95 | + final shape = StadiumBorder(side: borderSide); |
| 96 | + |
| 97 | + return Tooltip( |
| 98 | + excludeFromSemantics: true, // TODO: Semantics with eg "Reaction: <emoji name>; you and N others: <names>" |
| 99 | + message: emojiName, |
| 100 | + child: Material( |
| 101 | + color: backgroundColor, |
| 102 | + shape: shape, |
| 103 | + child: InkWell( |
| 104 | + customBorder: shape, |
| 105 | + splashColor: splashColor, |
| 106 | + highlightColor: highlightColor, |
| 107 | + onTap: () { |
| 108 | + (selfVoted ? removeReaction : addReaction).call(store.connection, |
| 109 | + messageId: messageId, |
| 110 | + reactionType: reactionType, |
| 111 | + emojiCode: emojiCode, |
| 112 | + emojiName: emojiName, |
| 113 | + ); |
| 114 | + }, |
| 115 | + child: Padding( |
| 116 | + // 1px of this padding accounts for the border, which Flutter |
| 117 | + // just paints without changing size. |
| 118 | + // |
| 119 | + // Separately, web has 1px less than this on the left, but that |
| 120 | + // asymmetry doesn't seem to help us. |
| 121 | + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), |
| 122 | + child: Row(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ |
| 123 | + Padding(padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1), |
| 124 | + child: (() { |
| 125 | + if (emojiset == Emojiset.text) { |
| 126 | + return _TextEmoji(emojiName: emojiName, selected: selfVoted); |
| 127 | + } |
| 128 | + switch (reactionType) { |
| 129 | + case ReactionType.unicodeEmoji: |
| 130 | + return _UnicodeEmoji( |
| 131 | + emojiCode: emojiCode, |
| 132 | + emojiName: emojiName, |
| 133 | + selected: selfVoted, |
| 134 | + ); |
| 135 | + case ReactionType.realmEmoji: |
| 136 | + case ReactionType.zulipExtraEmoji: |
| 137 | + return _ImageEmoji( |
| 138 | + emojiCode: emojiCode, |
| 139 | + emojiName: emojiName, |
| 140 | + selected: selfVoted, |
| 141 | + ); |
| 142 | + } |
| 143 | + })()), |
| 144 | + Flexible( |
| 145 | + // Added vertical: 1 to give some space when the label is |
| 146 | + // taller than the emoji (e.g. because it needs multiple lines) |
| 147 | + child: Padding(padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1), |
| 148 | + child: Text( |
| 149 | + textWidthBasis: TextWidthBasis.longestLine, |
| 150 | + style: TextStyle( |
| 151 | + fontFamily: 'Source Sans 3', |
| 152 | + fontSize: (14 * 0.90), |
| 153 | + height: 13 / (14 * 0.90), |
| 154 | + color: labelColor, |
| 155 | + ).merge(selfVoted |
| 156 | + ? weightVariableTextStyle(context, wght: 600, wghtIfPlatformRequestsBold: 900) |
| 157 | + : weightVariableTextStyle(context)), |
| 158 | + label)), |
| 159 | + ), |
| 160 | + ]))))); |
| 161 | + } |
| 162 | +} |
| 163 | + |
| 164 | +class _UnicodeEmoji extends StatelessWidget { |
| 165 | + const _UnicodeEmoji({ |
| 166 | + required this.emojiCode, |
| 167 | + required this.emojiName, |
| 168 | + required this.selected, |
| 169 | + }); |
| 170 | + |
| 171 | + final String emojiCode; |
| 172 | + final String emojiName; |
| 173 | + final bool selected; |
| 174 | + |
| 175 | + @override |
| 176 | + Widget build(BuildContext context) { |
| 177 | + final parsed = tryParseEmojiCodeToUnicode(emojiCode); |
| 178 | + if (parsed == null) { |
| 179 | + return _TextEmoji(emojiName: emojiName, selected: selected); |
| 180 | + } |
| 181 | + final textScaler = MediaQuery.textScalerOf(context); |
| 182 | + return SizedBox( |
| 183 | + width: textScaler.scale(17), |
| 184 | + child: Text( |
| 185 | + style: const TextStyle(fontSize: 17), |
| 186 | + strutStyle: const StrutStyle(fontSize: 17, forceStrutHeight: true), |
| 187 | + parsed)); |
| 188 | + } |
| 189 | +} |
| 190 | + |
| 191 | +class _ImageEmoji extends StatelessWidget { |
| 192 | + const _ImageEmoji({ |
| 193 | + required this.emojiCode, |
| 194 | + required this.emojiName, |
| 195 | + required this.selected, |
| 196 | + }); |
| 197 | + |
| 198 | + final String emojiCode; |
| 199 | + final String emojiName; |
| 200 | + final bool selected; |
| 201 | + |
| 202 | + Widget get _textFallback => _TextEmoji(emojiName: emojiName, selected: selected); |
| 203 | + |
| 204 | + @override |
| 205 | + Widget build(BuildContext context) { |
| 206 | + final store = PerAccountStoreWidget.of(context); |
| 207 | + |
| 208 | + // Some people really dislike animated emoji. |
| 209 | + final doNotAnimate = MediaQuery.of(context).disableAnimations |
| 210 | + || PlatformDispatcher.instance.accessibilityFeatures.reduceMotion; |
| 211 | + |
| 212 | + String src; |
| 213 | + switch (emojiCode) { |
| 214 | + case 'zulip': // the single "zulip extra emoji" |
| 215 | + src = '/static/generated/emoji/images/emoji/unicode/zulip.png'; |
| 216 | + default: |
| 217 | + final item = store.realmEmoji[emojiCode]; |
| 218 | + if (item == null) { |
| 219 | + return _textFallback; |
| 220 | + } |
| 221 | + src = doNotAnimate && item.stillUrl != null ? item.stillUrl! : item.sourceUrl; |
| 222 | + } |
| 223 | + final parsedSrc = Uri.tryParse(src); |
| 224 | + if (parsedSrc == null) { |
| 225 | + return _textFallback; |
| 226 | + } |
| 227 | + final resolved = store.account.realmUrl.resolveUri(parsedSrc); |
| 228 | + |
| 229 | + // Unicode emoji get scaled; it would look weird if image emoji didn't. |
| 230 | + final size = MediaQuery.textScalerOf(context).scale(17); |
| 231 | + |
| 232 | + return Center( |
| 233 | + child: RealmContentNetworkImage( |
| 234 | + resolved, |
| 235 | + width: size, |
| 236 | + height: size, |
| 237 | + errorBuilder: (context, _, __) => _textFallback, |
| 238 | + )); |
| 239 | + } |
| 240 | +} |
| 241 | + |
| 242 | +class _TextEmoji extends StatelessWidget { |
| 243 | + final String emojiName; |
| 244 | + final bool selected; |
| 245 | + |
| 246 | + const _TextEmoji({required this.emojiName, required this.selected}); |
| 247 | + |
| 248 | + @override |
| 249 | + Widget build(BuildContext context) { |
| 250 | + return SizedBox( |
| 251 | + height: 17, |
| 252 | + child: Center( |
| 253 | + child: Text( |
| 254 | + style: TextStyle( |
| 255 | + fontFamily: 'Source Sans 3', |
| 256 | + fontSize: 14 * 0.8, |
| 257 | + height: 16 / (14 * 0.8), |
| 258 | + color: selected ? _textColorSelected : _textColorUnselected, |
| 259 | + ).merge(selected |
| 260 | + ? weightVariableTextStyle(context, wght: 600, wghtIfPlatformRequestsBold: 900) |
| 261 | + : weightVariableTextStyle(context)), |
| 262 | + ':$emojiName:'))); |
| 263 | + } |
| 264 | +} |
0 commit comments