implemented enlarged emoji rendering for chat messages containing up to three emojis
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import 'package:characters/characters.dart';
|
||||
|
||||
/// Detects chat messages that consist solely of emoji so the UI can render
|
||||
/// them enlarged (à la WhatsApp/iMessage).
|
||||
///
|
||||
/// Returns the font size to use for such a message, or `null` when [text]
|
||||
/// contains any non-emoji, non-whitespace character (i.e. render normally).
|
||||
|
||||
const int _zwj = 0x200D;
|
||||
const int _vs15 = 0xFE0E;
|
||||
const int _vs16 = 0xFE0F;
|
||||
const int _keycap = 0x20E3;
|
||||
|
||||
/// Codepoints that only ever glue an emoji sequence together and never stand
|
||||
/// on their own — they don't disqualify a cluster from counting as emoji.
|
||||
bool _isModifier(int r) =>
|
||||
r == _zwj ||
|
||||
r == _vs15 ||
|
||||
r == _vs16 ||
|
||||
r == _keycap ||
|
||||
(r >= 0x1F3FB && r <= 0x1F3FF) || // skin-tone modifiers
|
||||
(r >= 0xE0020 && r <= 0xE007F); // tag characters (flag sequences)
|
||||
|
||||
/// The digits, `#` and `*` that become emoji only when combined with the
|
||||
/// keycap combining mark.
|
||||
bool _isKeycapBase(int r) =>
|
||||
r == 0x23 || r == 0x2A || (r >= 0x30 && r <= 0x39);
|
||||
|
||||
bool _isEmojiRune(int r) {
|
||||
// Bulk of modern emoji live in the astral pictograph block: emoticons,
|
||||
// transport, supplemental symbols, extended-A, regional indicators, …
|
||||
if (r >= 0x1F000 && r <= 0x1FAFF) return true;
|
||||
// Scattered BMP emoji ranges (misc symbols, dingbats, arrows, stars, …).
|
||||
if (r >= 0x2600 && r <= 0x27BF) return true;
|
||||
if (r >= 0x2300 && r <= 0x23FF) return true;
|
||||
if (r >= 0x2B00 && r <= 0x2BFF) return true;
|
||||
if (r >= 0x2190 && r <= 0x21FF) return true;
|
||||
if (r >= 0x25A0 && r <= 0x25FF) return true;
|
||||
const singles = {
|
||||
0x00A9, 0x00AE, 0x203C, 0x2049, 0x2122, 0x2139,
|
||||
0x2934, 0x2935, 0x3030, 0x303D, 0x3297, 0x3299, 0x24C2,
|
||||
};
|
||||
return singles.contains(r);
|
||||
}
|
||||
|
||||
bool _isEmojiCluster(String cluster) {
|
||||
final runes = cluster.runes.toList(growable: false);
|
||||
final hasKeycap = runes.contains(_keycap);
|
||||
var hasEmoji = false;
|
||||
for (final r in runes) {
|
||||
if (_isModifier(r)) continue;
|
||||
if (_isEmojiRune(r)) {
|
||||
hasEmoji = true;
|
||||
continue;
|
||||
}
|
||||
if (hasKeycap && _isKeycapBase(r)) {
|
||||
hasEmoji = true;
|
||||
continue;
|
||||
}
|
||||
return false; // a plain text character → not an emoji cluster
|
||||
}
|
||||
return hasEmoji;
|
||||
}
|
||||
|
||||
/// Maximum number of emojis a message may contain to still be enlarged; longer
|
||||
/// emoji strings would turn into a wall of giant glyphs and render normally.
|
||||
const int _maxEnlargedEmojis = 3;
|
||||
|
||||
/// Font size for an emoji-only [text] of at most [_maxEnlargedEmojis] emojis.
|
||||
/// `null` means the message is not emoji-only (or has too many emojis) and
|
||||
/// should render at the normal body size.
|
||||
double? standaloneEmojiFontSize(String text) {
|
||||
final trimmed = text.trim();
|
||||
if (trimmed.isEmpty) return null;
|
||||
|
||||
var count = 0;
|
||||
for (final cluster in trimmed.characters) {
|
||||
if (cluster.trim().isEmpty) continue; // whitespace between emojis
|
||||
if (!_isEmojiCluster(cluster)) return null;
|
||||
count++;
|
||||
if (count > _maxEnlargedEmojis) return null;
|
||||
}
|
||||
|
||||
if (count == 0) return null;
|
||||
return 34;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
|
||||
import '../../../../model/account_data.dart';
|
||||
import '../../../../model/endpoint_data.dart';
|
||||
import '../../../../utils/emoji_detection.dart';
|
||||
import '../../../../utils/url_opener.dart';
|
||||
import '../widgets/highlighted_linkify.dart';
|
||||
|
||||
@@ -28,13 +29,29 @@ class ChatMessage {
|
||||
}
|
||||
|
||||
Widget getWidget({String? highlightQuery, TextStyle? style}) {
|
||||
var contentWidget = HighlightedLinkify(
|
||||
final emojiFontSize = standaloneEmojiFontSize(content);
|
||||
final effectiveStyle = emojiFontSize == null
|
||||
? style
|
||||
: (style ?? const TextStyle()).copyWith(
|
||||
fontSize: emojiFontSize,
|
||||
height: 1.15,
|
||||
);
|
||||
|
||||
Widget contentWidget = HighlightedLinkify(
|
||||
text: content,
|
||||
onOpen: UrlOpener.onOpen,
|
||||
highlight: highlightQuery,
|
||||
style: style,
|
||||
style: effectiveStyle,
|
||||
);
|
||||
|
||||
// Enlarged emoji glyphs otherwise sit flush against the bubble edges.
|
||||
if (emojiFontSize != null) {
|
||||
contentWidget = Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: contentWidget,
|
||||
);
|
||||
}
|
||||
|
||||
if (originalData?['object']?.type == RichObjectStringObjectType.talkPoll) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.poll_outlined),
|
||||
|
||||
@@ -21,6 +21,9 @@ dependencies:
|
||||
async: ^2.11.0
|
||||
badges: ^3.1.2
|
||||
cached_network_image: ^3.4.1
|
||||
# Grapheme-cluster iteration for emoji-only message detection
|
||||
# (lib/utils/emoji_detection.dart). Already present transitively via flutter.
|
||||
characters: ^1.4.0
|
||||
collection: ^1.19.0
|
||||
connectivity_plus: ^7.1.0
|
||||
crypto: ^3.0.6
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/utils/emoji_detection.dart';
|
||||
|
||||
void main() {
|
||||
group('standaloneEmojiFontSize', () {
|
||||
test('returns null for empty or whitespace-only text', () {
|
||||
expect(standaloneEmojiFontSize(''), isNull);
|
||||
expect(standaloneEmojiFontSize(' '), isNull);
|
||||
});
|
||||
|
||||
test('returns null for plain text', () {
|
||||
expect(standaloneEmojiFontSize('Hallo'), isNull);
|
||||
expect(standaloneEmojiFontSize('ok'), isNull);
|
||||
});
|
||||
|
||||
test('returns null when emoji is mixed with text', () {
|
||||
expect(standaloneEmojiFontSize('Hallo 😀'), isNull);
|
||||
expect(standaloneEmojiFontSize('😀 super'), isNull);
|
||||
});
|
||||
|
||||
test('enlarges a single emoji the most', () {
|
||||
expect(standaloneEmojiFontSize('😀'), 34);
|
||||
expect(standaloneEmojiFontSize('👍'), 34);
|
||||
});
|
||||
|
||||
test('enlarges up to three emojis at the largest size', () {
|
||||
expect(standaloneEmojiFontSize('😀😀😀'), 34);
|
||||
expect(standaloneEmojiFontSize('😀 😀 😀'), 34);
|
||||
});
|
||||
|
||||
test('does not enlarge more than three emojis', () {
|
||||
expect(standaloneEmojiFontSize('😀😀😀😀'), isNull);
|
||||
expect(standaloneEmojiFontSize('😀😀😀😀😀😀'), isNull);
|
||||
});
|
||||
|
||||
test('handles ZWJ sequences as a single emoji', () {
|
||||
// Family emoji: multiple codepoints joined via ZWJ → one cluster.
|
||||
expect(standaloneEmojiFontSize('👨👩👧👦'), 34);
|
||||
});
|
||||
|
||||
test('handles skin-tone modified emoji as a single emoji', () {
|
||||
expect(standaloneEmojiFontSize('👍🏽'), 34);
|
||||
});
|
||||
|
||||
test('handles emoji with variation selectors', () {
|
||||
expect(standaloneEmojiFontSize('❤️'), 34);
|
||||
expect(standaloneEmojiFontSize('⭐'), 34);
|
||||
});
|
||||
|
||||
test('handles keycap emoji', () {
|
||||
expect(standaloneEmojiFontSize('1️⃣'), 34);
|
||||
});
|
||||
|
||||
test('handles flag emoji (regional indicators)', () {
|
||||
expect(standaloneEmojiFontSize('🇩🇪'), 34);
|
||||
});
|
||||
|
||||
test('bare digits without keycap are not emoji', () {
|
||||
expect(standaloneEmojiFontSize('123'), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user