|
| 1 | +import 'package:flutter/foundation.dart'; |
| 2 | +import 'package:flutter/material.dart'; |
| 3 | + |
| 4 | +import '../api/model/initial_snapshot.dart'; |
| 5 | +import '../api/model/model.dart'; |
| 6 | +import '../api/route/messages.dart'; |
| 7 | +import '../model/content.dart'; |
| 8 | +import 'content.dart'; |
| 9 | +import 'store.dart'; |
| 10 | +import 'text.dart'; |
| 11 | + |
| 12 | +class ReactionChipsList extends StatelessWidget { |
| 13 | + const ReactionChipsList({ |
| 14 | + super.key, |
| 15 | + required this.messageId, |
| 16 | + required this.reactions, |
| 17 | + }); |
| 18 | + |
| 19 | + final int messageId; |
| 20 | + final Reactions reactions; |
| 21 | + |
| 22 | + @override |
| 23 | + Widget build(BuildContext context) { |
| 24 | + final store = PerAccountStoreWidget.of(context); |
| 25 | + final displayEmojiReactionUsers = store.userSettings?.displayEmojiReactionUsers ?? false; |
| 26 | + final showNames = displayEmojiReactionUsers && reactions.total <= 3; |
| 27 | + |
| 28 | + return Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, |
| 29 | + children: reactions.aggregated.map((reactionVotes) => ReactionChip( |
| 30 | + showName: showNames, |
| 31 | + messageId: messageId, reactionWithVotes: reactionVotes), |
| 32 | + ).toList()); |
| 33 | + } |
| 34 | +} |
| 35 | + |
| 36 | +final _textColorSelected = const HSLColor.fromAHSL(1, 210, 0.20, 0.20).toColor(); |
| 37 | +final _textColorUnselected = const HSLColor.fromAHSL(1, 210, 0.20, 0.25).toColor(); |
| 38 | + |
| 39 | +const _backgroundColorSelected = Colors.white; |
| 40 | +// TODO shadow effect, following web, which uses `box-shadow: inset`: |
| 41 | +// https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow#inset |
| 42 | +// Needs Flutter support for something like that: |
| 43 | +// https://github.com/flutter/flutter/issues/18636 |
| 44 | +// https://github.com/flutter/flutter/issues/52999 |
| 45 | +// Until then use a solid color; a much-lightened version of the shadow color. |
| 46 | +// Also adapt by making [_borderColorUnselected] more transparent, so we'll |
| 47 | +// want to check that against web when implementing the shadow. |
| 48 | +final _backgroundColorUnselected = const HSLColor.fromAHSL(0.15, 210, 0.50, 0.875).toColor(); |
| 49 | + |
| 50 | +final _borderColorSelected = Colors.black.withOpacity(0.40); |
| 51 | +// TODO see TODO on [_backgroundColorUnselected] about shadow effect |
| 52 | +final _borderColorUnselected = Colors.black.withOpacity(0.06); |
| 53 | + |
| 54 | +class ReactionChip extends StatelessWidget { |
| 55 | + final bool showName; |
| 56 | + final int messageId; |
| 57 | + final ReactionWithVotes reactionWithVotes; |
| 58 | + |
| 59 | + const ReactionChip({ |
| 60 | + super.key, |
| 61 | + required this.showName, |
| 62 | + required this.messageId, |
| 63 | + required this.reactionWithVotes, |
| 64 | + }); |
| 65 | + |
| 66 | + @override |
| 67 | + Widget build(BuildContext context) { |
| 68 | + final store = PerAccountStoreWidget.of(context); |
| 69 | + |
| 70 | + final reactionType = reactionWithVotes.reactionType; |
| 71 | + final emojiCode = reactionWithVotes.emojiCode; |
| 72 | + final emojiName = reactionWithVotes.emojiName; |
| 73 | + final userIds = reactionWithVotes.userIds; |
| 74 | + |
| 75 | + final emojiset = store.userSettings?.emojiset ?? Emojiset.google; |
| 76 | + |
| 77 | + final selfUserId = store.account.userId; |
| 78 | + final selfVoted = userIds.contains(selfUserId); |
| 79 | + final label = showName |
| 80 | + // TODO(i18n): List formatting, like you can do in JavaScript: |
| 81 | + // new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya', 'Shu']) |
| 82 | + // // 'Chris、Greg、Alya、Shu' |
| 83 | + ? userIds.map((id) { |
| 84 | + return id == selfUserId |
| 85 | + ? 'You' |
| 86 | + : store.users[id]?.fullName ?? '(unknown user)'; // TODO(i18n) |
| 87 | + }).join(', ') |
| 88 | + : userIds.length.toString(); |
| 89 | + |
| 90 | + final borderColor = selfVoted ? _borderColorSelected : _borderColorUnselected; |
| 91 | + final labelColor = selfVoted ? _textColorSelected : _textColorUnselected; |
| 92 | + final backgroundColor = selfVoted ? _backgroundColorSelected : _backgroundColorUnselected; |
| 93 | + final splashColor = selfVoted ? _backgroundColorUnselected : _backgroundColorSelected; |
| 94 | + final highlightColor = splashColor.withOpacity(0.5); |
| 95 | + |
| 96 | + final borderSide = BorderSide(color: borderColor, width: 1); |
| 97 | + final shape = StadiumBorder(side: borderSide); |
| 98 | + |
| 99 | + final Widget emoji; |
| 100 | + if (emojiset == Emojiset.text) { |
| 101 | + emoji = _TextEmoji(emojiName: emojiName, selected: selfVoted); |
| 102 | + } else { |
| 103 | + switch (reactionType) { |
| 104 | + case ReactionType.unicodeEmoji: |
| 105 | + emoji = _UnicodeEmoji( |
| 106 | + emojiCode: emojiCode, |
| 107 | + emojiName: emojiName, |
| 108 | + selected: selfVoted, |
| 109 | + ); |
| 110 | + case ReactionType.realmEmoji: |
| 111 | + case ReactionType.zulipExtraEmoji: |
| 112 | + emoji = _ImageEmoji( |
| 113 | + emojiCode: emojiCode, |
| 114 | + emojiName: emojiName, |
| 115 | + selected: selfVoted, |
| 116 | + ); |
| 117 | + } |
| 118 | + } |
| 119 | + |
| 120 | + return Tooltip( |
| 121 | + // TODO(#434): Semantics with eg "Reaction: <emoji name>; you and N others: <names>" |
| 122 | + excludeFromSemantics: true, |
| 123 | + message: emojiName, |
| 124 | + child: Material( |
| 125 | + color: backgroundColor, |
| 126 | + shape: shape, |
| 127 | + child: InkWell( |
| 128 | + customBorder: shape, |
| 129 | + splashColor: splashColor, |
| 130 | + highlightColor: highlightColor, |
| 131 | + onTap: () { |
| 132 | + (selfVoted ? removeReaction : addReaction).call(store.connection, |
| 133 | + messageId: messageId, |
| 134 | + reactionType: reactionType, |
| 135 | + emojiCode: emojiCode, |
| 136 | + emojiName: emojiName, |
| 137 | + ); |
| 138 | + }, |
| 139 | + child: Padding( |
| 140 | + // 1px of this padding accounts for the border, which Flutter |
| 141 | + // just paints without changing size. |
| 142 | + padding: const EdgeInsetsDirectional.fromSTEB(4, 2, 5, 2), |
| 143 | + child: LayoutBuilder( |
| 144 | + builder: (context, constraints) { |
| 145 | + final maxRowWidth = constraints.maxWidth; |
| 146 | + // To give text emojis some room so they need fewer line breaks |
| 147 | + // when the label is long. |
| 148 | + // TODO(#433) This is a bit overzealous. The shorter width |
| 149 | + // won't be necessary when the text emoji is very short, or |
| 150 | + // in the near-universal case of small, square emoji (i.e. |
| 151 | + // Unicode and image emoji). But it's not simple to recognize |
| 152 | + // those cases here: we don't know at this point whether we'll |
| 153 | + // be showing a text emoji, because we use that for various |
| 154 | + // error conditions (including when an image fails to load, |
| 155 | + // which we learn about especially late). |
| 156 | + final maxLabelWidth = (maxRowWidth - 6) * 0.75; // 6 is padding |
| 157 | + |
| 158 | + return Row( |
| 159 | + mainAxisSize: MainAxisSize.min, |
| 160 | + crossAxisAlignment: CrossAxisAlignment.center, |
| 161 | + children: [ |
| 162 | + // So text-emoji chips are at least as tall as square-emoji |
| 163 | + // ones (probably a good thing). |
| 164 | + SizedBox(height: _squareEmojiScalerClamped(context).scale(_squareEmojiSize)), |
| 165 | + Flexible( // [Flexible] to let text emojis expand if they can |
| 166 | + child: Padding(padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1), |
| 167 | + child: emoji)), |
| 168 | + // Added vertical: 1 to give some space when the label is |
| 169 | + // taller than the emoji (e.g. because it needs multiple lines) |
| 170 | + Padding(padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1), |
| 171 | + child: Container( |
| 172 | + constraints: BoxConstraints(maxWidth: maxLabelWidth), |
| 173 | + child: Text( |
| 174 | + textWidthBasis: TextWidthBasis.longestLine, |
| 175 | + textScaler: _labelTextScalerClamped(context), |
| 176 | + style: TextStyle( |
| 177 | + fontFamily: 'Source Sans 3', |
| 178 | + fontSize: (14 * 0.90), |
| 179 | + height: 13 / (14 * 0.90), |
| 180 | + color: labelColor, |
| 181 | + ).merge(selfVoted |
| 182 | + ? weightVariableTextStyle(context, wght: 600, wghtIfPlatformRequestsBold: 900) |
| 183 | + : weightVariableTextStyle(context)), |
| 184 | + label), |
| 185 | + )), |
| 186 | + ]); |
| 187 | + }))))); |
| 188 | + } |
| 189 | +} |
| 190 | + |
| 191 | +/// The size of a square emoji (Unicode or image). |
| 192 | +/// |
| 193 | +/// Should be scaled by [_emojiTextScalerClamped]. |
| 194 | +const _squareEmojiSize = 17.0; |
| 195 | + |
| 196 | +/// A font size that, with Noto Color Emoji and our line-height config, |
| 197 | +/// causes a Unicode emoji to occupy a [_squareEmojiSize] square in the layout. |
| 198 | +/// |
| 199 | +/// Determined experimentally: |
| 200 | +/// <https://github.com/zulip/zulip-flutter/pull/410#discussion_r1402808701> |
| 201 | +// TODO(#404) Actually bundle Noto Color Emoji with the app. Some Android |
| 202 | +// phones use Noto Color Emoji automatically, and some don't; e.g., Samsung |
| 203 | +// has its own emoji font: |
| 204 | +// <https://github.com/zulip/zulip-flutter/pull/410#discussion_r1408403111> |
| 205 | +const _notoColorEmojiTextSize = 14.5; |
| 206 | + |
| 207 | +/// A [TextScaler] that limits Unicode and image emojis' max scale factor, |
| 208 | +/// to leave space for the label. |
| 209 | +/// |
| 210 | +/// This should scale [_squareEmojiSize] for Unicode and image emojis. |
| 211 | +// TODO(a11y) clamp higher? |
| 212 | +TextScaler _squareEmojiScalerClamped(BuildContext context) => |
| 213 | + MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 2); |
| 214 | + |
| 215 | +/// A [TextScaler] that limits text emojis' max scale factor, |
| 216 | +/// to minimize the need for line breaks. |
| 217 | +// TODO(a11y) clamp higher? |
| 218 | +TextScaler _textEmojiScalerClamped(BuildContext context) => |
| 219 | + MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5); |
| 220 | + |
| 221 | +/// A [TextScaler] that limits the label's max scale factor, |
| 222 | +/// to minimize the need for line breaks. |
| 223 | +// TODO(a11y) clamp higher? |
| 224 | +TextScaler _labelTextScalerClamped(BuildContext context) => |
| 225 | + MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 2); |
| 226 | + |
| 227 | +class _UnicodeEmoji extends StatelessWidget { |
| 228 | + const _UnicodeEmoji({ |
| 229 | + required this.emojiCode, |
| 230 | + required this.emojiName, |
| 231 | + required this.selected, |
| 232 | + }); |
| 233 | + |
| 234 | + final String emojiCode; |
| 235 | + final String emojiName; |
| 236 | + final bool selected; |
| 237 | + |
| 238 | + @override |
| 239 | + Widget build(BuildContext context) { |
| 240 | + final parsed = tryParseEmojiCodeToUnicode(emojiCode); |
| 241 | + if (parsed == null) { // TODO(log) |
| 242 | + return _TextEmoji(emojiName: emojiName, selected: selected); |
| 243 | + } |
| 244 | + |
| 245 | + switch (defaultTargetPlatform) { |
| 246 | + case TargetPlatform.android: |
| 247 | + case TargetPlatform.fuchsia: |
| 248 | + case TargetPlatform.linux: |
| 249 | + case TargetPlatform.windows: |
| 250 | + return Text( |
| 251 | + textScaler: _squareEmojiScalerClamped(context), |
| 252 | + style: const TextStyle(fontSize: _notoColorEmojiTextSize), |
| 253 | + strutStyle: const StrutStyle(fontSize: _notoColorEmojiTextSize, forceStrutHeight: true), |
| 254 | + parsed); |
| 255 | + case TargetPlatform.iOS: |
| 256 | + case TargetPlatform.macOS: |
| 257 | + // We expect the font "Apple Color Emoji" to be used. There are some |
| 258 | + // surprises in how Flutter ends up rendering emojis in this font: |
| 259 | + // - With a font size of 17px, the emoji visually seems to be about 17px |
| 260 | + // square. (Unlike on Android, with Noto Color Emoji, where a 14.5px font |
| 261 | + // size gives an emoji that looks 17px square.) See: |
| 262 | + // <https://github.com/flutter/flutter/issues/28894> |
| 263 | + // - The emoji doesn't fill the space taken by the [Text] in the layout. |
| 264 | + // There's whitespace above, below, and on the right. See: |
| 265 | + // <https://github.com/flutter/flutter/issues/119623> |
| 266 | + // |
| 267 | + // That extra space would be problematic, except we've used a [Stack] to |
| 268 | + // make the [Text] "positioned" so the space doesn't add margins around the |
| 269 | + // visible part. Key points that enable the [Stack] workaround: |
| 270 | + // - The emoji seems approximately vertically centered (this is |
| 271 | + // accomplished with help from a [StrutStyle]; see below). |
| 272 | + // - There seems to be approximately no space on its left. |
| 273 | + final boxSize = _squareEmojiScalerClamped(context).scale(_squareEmojiSize); |
| 274 | + return Stack(alignment: Alignment.centerLeft, clipBehavior: Clip.none, children: [ |
| 275 | + SizedBox(height: boxSize, width: boxSize), |
| 276 | + PositionedDirectional(start: 0, child: Text( |
| 277 | + textScaler: _squareEmojiScalerClamped(context), |
| 278 | + style: const TextStyle(fontSize: _squareEmojiSize), |
| 279 | + strutStyle: const StrutStyle(fontSize: _squareEmojiSize, forceStrutHeight: true), |
| 280 | + parsed)), |
| 281 | + ]); |
| 282 | + } |
| 283 | + } |
| 284 | +} |
| 285 | + |
| 286 | +class _ImageEmoji extends StatelessWidget { |
| 287 | + const _ImageEmoji({ |
| 288 | + required this.emojiCode, |
| 289 | + required this.emojiName, |
| 290 | + required this.selected, |
| 291 | + }); |
| 292 | + |
| 293 | + final String emojiCode; |
| 294 | + final String emojiName; |
| 295 | + final bool selected; |
| 296 | + |
| 297 | + Widget get _textFallback => _TextEmoji(emojiName: emojiName, selected: selected); |
| 298 | + |
| 299 | + @override |
| 300 | + Widget build(BuildContext context) { |
| 301 | + final store = PerAccountStoreWidget.of(context); |
| 302 | + |
| 303 | + // Some people really dislike animated emoji. |
| 304 | + final doNotAnimate = |
| 305 | + // From reading code, this doesn't actually get set on iOS: |
| 306 | + // https://github.com/zulip/zulip-flutter/pull/410#discussion_r1408522293 |
| 307 | + MediaQuery.disableAnimationsOf(context) |
| 308 | + || (defaultTargetPlatform == TargetPlatform.iOS |
| 309 | + // TODO(upstream) On iOS 17+ (new in 2023), there's a more closely |
| 310 | + // relevant setting than "reduce motion". It's called "auto-play |
| 311 | + // animated images", and we should file an issue to expose it. |
| 312 | + // See GitHub comment linked above. |
| 313 | + && WidgetsBinding.instance.platformDispatcher.accessibilityFeatures.reduceMotion); |
| 314 | + |
| 315 | + final String src; |
| 316 | + switch (emojiCode) { |
| 317 | + case 'zulip': // the single "zulip extra emoji" |
| 318 | + src = '/static/generated/emoji/images/emoji/unicode/zulip.png'; |
| 319 | + default: |
| 320 | + final item = store.realmEmoji[emojiCode]; |
| 321 | + if (item == null) { |
| 322 | + return _textFallback; |
| 323 | + } |
| 324 | + src = doNotAnimate && item.stillUrl != null ? item.stillUrl! : item.sourceUrl; |
| 325 | + } |
| 326 | + final parsedSrc = Uri.tryParse(src); |
| 327 | + if (parsedSrc == null) { // TODO(log) |
| 328 | + return _textFallback; |
| 329 | + } |
| 330 | + final resolved = store.account.realmUrl.resolveUri(parsedSrc); |
| 331 | + |
| 332 | + // Unicode and text emoji get scaled; it would look weird if image emoji didn't. |
| 333 | + final size = _squareEmojiScalerClamped(context).scale(_squareEmojiSize); |
| 334 | + |
| 335 | + return RealmContentNetworkImage( |
| 336 | + resolved, |
| 337 | + width: size, |
| 338 | + height: size, |
| 339 | + errorBuilder: (context, _, __) => _textFallback, |
| 340 | + ); |
| 341 | + } |
| 342 | +} |
| 343 | + |
| 344 | +class _TextEmoji extends StatelessWidget { |
| 345 | + const _TextEmoji({required this.emojiName, required this.selected}); |
| 346 | + |
| 347 | + final String emojiName; |
| 348 | + final bool selected; |
| 349 | + |
| 350 | + @override |
| 351 | + Widget build(BuildContext context) { |
| 352 | + return Text( |
| 353 | + textAlign: TextAlign.end, |
| 354 | + textScaler: _textEmojiScalerClamped(context), |
| 355 | + style: TextStyle( |
| 356 | + fontFamily: 'Source Sans 3', |
| 357 | + fontSize: 14 * 0.8, |
| 358 | + height: 1, // to be denser when we have to wrap |
| 359 | + color: selected ? _textColorSelected : _textColorUnselected, |
| 360 | + ).merge(selected |
| 361 | + ? weightVariableTextStyle(context, wght: 600, wghtIfPlatformRequestsBold: 900) |
| 362 | + : weightVariableTextStyle(context)), |
| 363 | + // Encourage line breaks before "_" (common in these), but try not |
| 364 | + // to leave a colon alone on a line. See: |
| 365 | + // <https://github.com/flutter/flutter/issues/61081#issuecomment-1103330522> |
| 366 | + ':\ufeff${emojiName.replaceAll('_', '\u200b_')}\ufeff:'); |
| 367 | + } |
| 368 | +} |
0 commit comments