feat: реакции, так еще ебать и анимированные!!!!!! правда лагает пизда я это потом пофиксю мб как в тг сделаю

This commit is contained in:
Jganenokk
2026-07-10 00:38:03 +07:00
parent 94ca2016ba
commit c81cd91ecd
13 changed files with 602 additions and 176 deletions
+90 -20
View File
@@ -26,6 +26,8 @@ import '../../../main.dart';
import '../../../l10n/app_localizations.dart';
import '../../../backend/api.dart';
import '../../../backend/modules/messages.dart';
import '../../../backend/modules/animoji.dart';
import '../../../models/animoji.dart';
import '../../../backend/modules/complaints.dart';
import '../../../core/calls/call_controller.dart';
import '../calls/call_screen.dart';
@@ -39,6 +41,7 @@ import '../../../core/storage/draft_store.dart';
import '../../../core/cache/info_cache.dart';
import '../../../core/cache/message_session_cache.dart';
import '../../../core/utils/haptics.dart';
import '../../../core/utils/emoji_keyword_index.dart';
import '../../../core/utils/logger.dart';
import '../../../core/config/app_cache_extent.dart';
import '../../../core/config/app_colors.dart';
@@ -265,8 +268,52 @@ class _ChatScreenState extends State<ChatScreen>
}
void _reactToMessage(CachedMessage message, String emoji) {
if (message.isControl || message.id.startsWith('temp_')) return;
final notifier = _reactionNotifierFor(message);
notifier.value = _applyLocalReaction(notifier.value, emoji);
final previous = notifier.value;
final applied = _applyLocalReaction(previous, emoji);
notifier.value = applied;
final isToggleOff = applied == null || applied['yourReaction'] == null;
unawaited(_sendReaction(message, emoji, isToggleOff, previous));
}
Future<void> _sendReaction(
CachedMessage message,
String emoji,
bool isToggleOff,
Map<String, dynamic>? previous,
) async {
({bool ok, Map<String, dynamic>? info}) result;
try {
result = isToggleOff
? await messagesModule.cancelReaction(widget.chatId, message.id)
: await messagesModule.setReaction(widget.chatId, message.id, emoji);
} catch (_) {
result = (ok: false, info: null);
}
if (!mounted) return;
final notifier = _reactionNotifiers[message.id];
if (notifier == null) return;
if (!result.ok) {
notifier.value = previous;
Haptics.error();
showCustomNotification(context, 'Не удалось обновить реакцию');
return;
}
notifier.value = result.info;
_applyReactionInfoToMessage(message.id, result.info);
}
void _applyReactionInfoToMessage(String messageId, Map<String, dynamic>? info) {
final idx = _messages.indexWhere((m) => m.id == messageId);
if (idx == -1) return;
final payload = <String, dynamic>{...?_messages[idx].payload};
if (info == null) {
payload.remove('reactionInfo');
} else {
payload['reactionInfo'] = info;
}
_messages[idx] = _messages[idx].copyWith(payload: payload);
}
Map<String, dynamic>? _applyLocalReaction(
@@ -299,8 +346,9 @@ class _ChatScreenState extends State<ChatScreen>
final prev = current?['yourReaction']?.toString();
String? your;
if (prev == emoji) {
decrement(emoji);
if (prev != null &&
EmojiKeywordIndex.normalize(prev) == EmojiKeywordIndex.normalize(emoji)) {
decrement(prev);
your = null;
} else {
if (prev != null && prev.isNotEmpty) decrement(prev);
@@ -417,6 +465,7 @@ class _ChatScreenState extends State<ChatScreen>
_chatController.chatId = widget.chatId;
_chatController.isMounted = () => mounted;
unawaited(PushService.clearChatNotification(widget.chatId));
unawaited(animojiModule.ensureLoaded().catchError((_) {}));
WidgetsBinding.instance.addObserver(this);
chats.chatsChanged.addListener(_onChatsBump);
_messageController.addListener(_onTextChanged);
@@ -1186,27 +1235,14 @@ class _ChatScreenState extends State<ChatScreen>
void _syncReactionNotifiersFromMessages() {
for (final m in _messages) {
if (_reactionNotifiers.containsKey(m.id)) continue;
final info = m.payload?['reactionInfo'];
final value = info is Map ? Map<String, dynamic>.from(info) : null;
final existing = _reactionNotifiers[m.id];
if (existing == null) {
_reactionNotifiers[m.id] = ValueNotifier(value);
} else if (!_reactionsEqual(existing.value, value)) {
existing.value = value;
}
_reactionNotifiers[m.id] = ValueNotifier(
info is Map ? Map<String, dynamic>.from(info) : null,
);
}
}
bool _reactionsEqual(Map<String, dynamic>? a, Map<String, dynamic>? b) {
if (identical(a, b)) return true;
if (a == null || b == null) return false;
if (a.length != b.length) return false;
for (final k in a.keys) {
if (a[k].toString() != b[k].toString()) return false;
}
return true;
}
@override
void deactivate() {
_saveDraft();
@@ -4216,6 +4252,9 @@ class _ChatScreenState extends State<ChatScreen>
onReplyTap: _jumpToMessage,
onAvatarTap: _openSenderProfile,
onStickerTap: _openStickerPack,
onReactionTap: message.isControl
? null
: (emoji) => _reactToMessage(message, emoji),
peerName: widget.name,
peerAvatarUrl: widget.imageUrl,
);
@@ -5639,10 +5678,41 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> {
isPinned: _isPinnedNow(),
onReact: widget.onReact,
selectedReaction: widget.reactions?.value?['yourReaction']?.toString(),
quickReactions: _quickReactionEmojis(),
loadReactionEmojis: () async {
await animojiModule.ensureLoaded();
return _animojiReactionEmojis();
},
onDispose: controller.dispose,
);
}
List<ReactionEmoji> _quickReactionEmojis() {
final quick = animojiModule.quickAnimojis;
if (quick.isEmpty) {
return AnimojiModule.fallbackReactions
.map((e) => ReactionEmoji(emoji: e))
.toList();
}
return quick.map(_toReactionEmoji).toList();
}
List<ReactionEmoji> _animojiReactionEmojis() {
final list = animojiModule.animojis;
if (list.isEmpty) {
return AnimojiModule.fallbackReactions
.map((e) => ReactionEmoji(emoji: e))
.toList();
}
return list.map(_toReactionEmoji).toList();
}
ReactionEmoji _toReactionEmoji(Animoji a) => ReactionEmoji(
emoji: a.emoji,
animationUrl: a.lottieUrl,
staticUrl: a.iconUrl,
);
void _onSecondaryTapDown(TapDownDetails details) {
final ctx = _boundaryKey.currentContext;
if (ctx == null) return;
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../../models/attachment.dart';
import '../../sticker_image.dart';
import '../../lottie_image.dart';
import 'bubble_context.dart';
class StickerBubble extends StatelessWidget {
@@ -25,7 +25,7 @@ class StickerBubble extends StatelessWidget {
SizedBox(
width: 150,
height: 150,
child: StickerImage(
child: LottieImage(
url: staticUrl,
lottieUrl: lottieUrl,
size: 150,
@@ -6,14 +6,14 @@ import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:lottie/lottie.dart';
class StickerLoadGovernor {
StickerLoadGovernor._() {
class LottieLoadGovernor {
LottieLoadGovernor._() {
_budgetMs = _resolveBudgetMs();
_avgMs = _budgetMs;
SchedulerBinding.instance.addTimingsCallback(_onTimings);
}
static final StickerLoadGovernor instance = StickerLoadGovernor._();
static final LottieLoadGovernor instance = LottieLoadGovernor._();
final ValueNotifier<bool> throttled = ValueNotifier(false);
double _budgetMs = 1000 / 60;
@@ -43,7 +43,7 @@ class StickerLoadGovernor {
}
}
class _StickerFrames {
class _LottieFrames {
final LottieDrawable drawable;
final int frameCount;
final Duration duration;
@@ -54,7 +54,7 @@ class _StickerFrames {
int lastUsed = 0;
int active = 0;
_StickerFrames({
_LottieFrames({
required this.drawable,
required this.frameCount,
required this.duration,
@@ -69,7 +69,7 @@ class _StickerFrames {
}
final last = _lastImage;
if (last != null && StickerLoadGovernor.instance.throttled.value) {
if (last != null && LottieLoadGovernor.instance.throttled.value) {
return last;
}
@@ -90,7 +90,7 @@ class _StickerFrames {
_lastImage = image;
final added = pxSize * pxSize * 4;
bytes += added;
_StickerFrameCache.instance._onBytesAdded(added);
_LottieFrameCache.instance._onBytesAdded(added);
return image;
}
@@ -104,21 +104,21 @@ class _StickerFrames {
}
}
class _StickerFrameCache {
_StickerFrameCache._();
static final _StickerFrameCache instance = _StickerFrameCache._();
class _LottieFrameCache {
_LottieFrameCache._();
static final _LottieFrameCache instance = _LottieFrameCache._();
static const int _maxBytes = 384 * 1024 * 1024;
static const double _fps = 30;
final Map<String, _StickerFrames> _entries = {};
final Map<String, Future<_StickerFrames?>> _loading = {};
final Map<String, _LottieFrames> _entries = {};
final Map<String, Future<_LottieFrames?>> _loading = {};
int _totalBytes = 0;
int _clock = 0;
int _tick() => ++_clock;
Future<_StickerFrames?> acquire(String url, int pxSize) async {
Future<_LottieFrames?> acquire(String url, int pxSize) async {
final key = '$url@$pxSize';
final cached = _entries[key];
if (cached != null) {
@@ -146,13 +146,13 @@ class _StickerFrameCache {
return entry;
}
void release(_StickerFrames frames) {
void release(_LottieFrames frames) {
if (frames.active > 0) frames.active--;
frames.lastUsed = _tick();
_evictIfNeeded();
}
Future<_StickerFrames?> _load(String url, int pxSize, String key) async {
Future<_LottieFrames?> _load(String url, int pxSize, String key) async {
try {
final composition = await NetworkLottie(
url,
@@ -161,7 +161,7 @@ class _StickerFrameCache {
final durationMs = composition.duration.inMilliseconds;
var frameCount = (durationMs / 1000 * _fps).round();
frameCount = frameCount.clamp(1, 120);
final entry = _StickerFrames(
final entry = _LottieFrames(
drawable: LottieDrawable(composition),
frameCount: frameCount,
duration: durationMs <= 0
@@ -195,31 +195,31 @@ class _StickerFrameCache {
}
}
class StickerScrollScope extends InheritedWidget {
class LottieScrollScope extends InheritedWidget {
final ValueListenable<bool> isScrolling;
const StickerScrollScope({
const LottieScrollScope({
super.key,
required this.isScrolling,
required super.child,
});
static ValueListenable<bool>? of(BuildContext context) => context
.dependOnInheritedWidgetOfExactType<StickerScrollScope>()
.dependOnInheritedWidgetOfExactType<LottieScrollScope>()
?.isScrolling;
@override
bool updateShouldNotify(StickerScrollScope oldWidget) =>
bool updateShouldNotify(LottieScrollScope oldWidget) =>
!identical(oldWidget.isScrolling, isScrolling);
}
class StickerLottie extends StatefulWidget {
class LottiePlayer extends StatefulWidget {
final String lottieUrl;
final String? fallbackUrl;
final double? size;
final int? memCacheWidth;
const StickerLottie({
const LottiePlayer({
super.key,
required this.lottieUrl,
this.fallbackUrl,
@@ -228,14 +228,14 @@ class StickerLottie extends StatefulWidget {
});
@override
State<StickerLottie> createState() => _StickerLottieState();
State<LottiePlayer> createState() => _LottiePlayerState();
}
class _StickerLottieState extends State<StickerLottie>
class _LottiePlayerState extends State<LottiePlayer>
with SingleTickerProviderStateMixin {
final ValueNotifier<int> _frameIndex = ValueNotifier(0);
late final Ticker _ticker;
_StickerFrames? _frames;
_LottieFrames? _frames;
ValueListenable<bool>? _scrollState;
int? _px;
bool _started = false;
@@ -243,19 +243,19 @@ class _StickerLottieState extends State<StickerLottie>
bool get _isScrolling => _scrollState?.value ?? false;
bool get _canLoad =>
!_isScrolling && !StickerLoadGovernor.instance.throttled.value;
!_isScrolling && !LottieLoadGovernor.instance.throttled.value;
@override
void initState() {
super.initState();
_ticker = createTicker(_onTick);
StickerLoadGovernor.instance.throttled.addListener(_onGateChanged);
LottieLoadGovernor.instance.throttled.addListener(_onGateChanged);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final state = StickerScrollScope.of(context);
final state = LottieScrollScope.of(context);
if (!identical(state, _scrollState)) {
_scrollState?.removeListener(_onGateChanged);
_scrollState = state;
@@ -264,12 +264,12 @@ class _StickerLottieState extends State<StickerLottie>
}
@override
void didUpdateWidget(StickerLottie oldWidget) {
void didUpdateWidget(LottiePlayer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.lottieUrl != widget.lottieUrl) {
_ticker.stop();
final previous = _frames;
if (previous != null) _StickerFrameCache.instance.release(previous);
if (previous != null) _LottieFrameCache.instance.release(previous);
_frames = null;
_started = false;
_showedFrames = false;
@@ -278,11 +278,11 @@ class _StickerLottieState extends State<StickerLottie>
@override
void dispose() {
StickerLoadGovernor.instance.throttled.removeListener(_onGateChanged);
LottieLoadGovernor.instance.throttled.removeListener(_onGateChanged);
_scrollState?.removeListener(_onGateChanged);
_ticker.dispose();
final frames = _frames;
if (frames != null) _StickerFrameCache.instance.release(frames);
if (frames != null) _LottieFrameCache.instance.release(frames);
_frameIndex.dispose();
super.dispose();
}
@@ -327,10 +327,10 @@ class _StickerLottieState extends State<StickerLottie>
final px = _px;
if (_started || px == null) return;
_started = true;
_StickerFrameCache.instance.acquire(widget.lottieUrl, px).then((frames) {
_LottieFrameCache.instance.acquire(widget.lottieUrl, px).then((frames) {
if (frames == null) return;
if (!mounted) {
_StickerFrameCache.instance.release(frames);
_LottieFrameCache.instance.release(frames);
return;
}
setState(() => _frames = frames);
@@ -382,3 +382,47 @@ class _StickerLottieState extends State<StickerLottie>
);
}
}
class LottieImage extends StatelessWidget {
final String? url;
final String? lottieUrl;
final double? size;
final int? memCacheWidth;
const LottieImage({
super.key,
this.url,
this.lottieUrl,
this.size,
this.memCacheWidth,
});
@override
Widget build(BuildContext context) {
if (lottieUrl != null && lottieUrl!.isNotEmpty) {
return LottiePlayer(
lottieUrl: lottieUrl!,
fallbackUrl: url,
size: size,
memCacheWidth: memCacheWidth,
);
}
return _static();
}
Widget _static() {
final src = url ?? '';
final blank = SizedBox(width: size, height: size);
if (src.isEmpty) return blank;
return CachedNetworkImage(
imageUrl: src,
width: size,
height: size,
fit: BoxFit.contain,
memCacheWidth: memCacheWidth,
fadeInDuration: const Duration(milliseconds: 120),
placeholder: (_, _) => blank,
errorWidget: (_, _, _) => blank,
);
}
}
+125 -37
View File
@@ -12,6 +12,19 @@ import '../../core/utils/format.dart';
import '../../core/utils/haptics.dart';
import '../../l10n/app_localizations.dart';
import 'custom_notification.dart';
import 'lottie_image.dart';
class ReactionEmoji {
final String emoji;
final String? animationUrl;
final String? staticUrl;
const ReactionEmoji({
required this.emoji,
this.animationUrl,
this.staticUrl,
});
}
enum MessageActionsInteraction { dragAndRelease, click, tap }
@@ -90,7 +103,15 @@ void showMessageActions({
bool isPinned = false,
void Function(String emoji)? onReact,
String? selectedReaction,
List<String> quickReactions = const ['👍', '❤️', '🔥', '😂', '😮', '😢'],
List<ReactionEmoji> quickReactions = const [
ReactionEmoji(emoji: '👍'),
ReactionEmoji(emoji: '❤️'),
ReactionEmoji(emoji: '🔥'),
ReactionEmoji(emoji: '🤣'),
ReactionEmoji(emoji: '😭'),
ReactionEmoji(emoji: '😍'),
],
Future<List<ReactionEmoji>> Function()? loadReactionEmojis,
MessageActionsInteraction interaction =
MessageActionsInteraction.dragAndRelease,
}) {
@@ -119,6 +140,7 @@ void showMessageActions({
onReact: onReact,
selectedReaction: selectedReaction,
quickReactions: quickReactions,
loadReactionEmojis: loadReactionEmojis,
onDismiss: () {
if (entry.mounted) entry.remove();
onDispose();
@@ -150,7 +172,8 @@ class _MessageActionsLayer extends StatefulWidget {
final bool isPinned;
final void Function(String emoji)? onReact;
final String? selectedReaction;
final List<String> quickReactions;
final List<ReactionEmoji> quickReactions;
final Future<List<ReactionEmoji>> Function()? loadReactionEmojis;
const _MessageActionsLayer({
required this.snapshot,
@@ -175,6 +198,7 @@ class _MessageActionsLayer extends StatefulWidget {
this.onReact,
this.selectedReaction,
this.quickReactions = const [],
this.loadReactionEmojis,
});
@override
@@ -846,13 +870,16 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
double radius,
double e,
double cell,
List<String> quick,
List<ReactionEmoji> quick,
Size expandedSize,
bool pillAbove,
) {
final borderRadius = BorderRadius.circular(radius);
_pickerCache ??= RepaintBoundary(
child: _ReactionEmojiPicker(onPick: _onReactionPicked),
child: _ReactionEmojiPicker(
onPick: _onReactionPicked,
loadEmojis: widget.loadReactionEmojis,
),
);
return DecoratedBox(
@@ -913,13 +940,13 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
);
}
Widget _buildQuickRow(ColorScheme cs, double cell, List<String> quick) {
Widget _buildQuickRow(ColorScheme cs, double cell, List<ReactionEmoji> quick) {
return Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: 6),
for (final emoji in quick) _quickEmoji(cs, emoji, cell),
for (final reaction in quick) _quickEmoji(cs, reaction, cell),
_chevronButton(cs),
const SizedBox(width: 4),
],
@@ -927,8 +954,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
);
}
Widget _quickEmoji(ColorScheme cs, String emoji, double cell) {
final selected = _isSelectedReaction(emoji);
Widget _quickEmoji(ColorScheme cs, ReactionEmoji reaction, double cell) {
final selected = _isSelectedReaction(reaction.emoji);
return SizedBox(
width: cell,
height: cell,
@@ -939,9 +966,9 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => _onReactionPicked(emoji),
onTap: () => _onReactionPicked(reaction.emoji),
child: Center(
child: Text(emoji, style: TextStyle(fontSize: cell * 0.6)),
child: _ReactionGlyph(reaction: reaction, size: cell * 0.72),
),
),
),
@@ -1380,7 +1407,8 @@ class _Action {
class _ReactionEmojiPicker extends StatefulWidget {
final ValueChanged<String> onPick;
const _ReactionEmojiPicker({required this.onPick});
final Future<List<ReactionEmoji>> Function()? loadEmojis;
const _ReactionEmojiPicker({required this.onPick, this.loadEmojis});
@override
State<_ReactionEmojiPicker> createState() => _ReactionEmojiPickerState();
@@ -1389,11 +1417,21 @@ class _ReactionEmojiPicker extends StatefulWidget {
class _ReactionEmojiPickerState extends State<_ReactionEmojiPicker> {
final TextEditingController _searchCtrl = TextEditingController();
final FocusNode _searchFocus = FocusNode();
List<String> _all = const [];
List<String> _results = const [];
final ValueNotifier<bool> _scrolling = ValueNotifier(false);
List<ReactionEmoji> _all = const [];
List<ReactionEmoji> _results = const [];
String _query = '';
bool _loaded = false;
bool _onScrollNotification(ScrollNotification n) {
if (n is ScrollStartNotification || n is ScrollUpdateNotification) {
if (!_scrolling.value) _scrolling.value = true;
} else if (n is ScrollEndNotification) {
if (_scrolling.value) _scrolling.value = false;
}
return false;
}
@override
void initState() {
super.initState();
@@ -1402,9 +1440,15 @@ class _ReactionEmojiPickerState extends State<_ReactionEmojiPicker> {
Future<void> _load() async {
await EmojiKeywordIndex.instance.ensureLoaded();
final loader = widget.loadEmojis;
final emojis = loader != null
? await loader()
: EmojiKeywordIndex.instance.all
.map((e) => ReactionEmoji(emoji: e))
.toList();
if (!mounted) return;
setState(() {
_all = EmojiKeywordIndex.instance.all;
_all = emojis;
_results = _all;
_loaded = true;
});
@@ -1414,7 +1458,19 @@ class _ReactionEmojiPickerState extends State<_ReactionEmojiPicker> {
final q = value.trim();
setState(() {
_query = q;
_results = q.isEmpty ? _all : EmojiKeywordIndex.instance.search(q);
if (q.isEmpty) {
_results = _all;
} else {
final matches = EmojiKeywordIndex.instance
.search(q)
.map(EmojiKeywordIndex.normalize)
.toSet();
_results = _all
.where(
(e) => matches.contains(EmojiKeywordIndex.normalize(e.emoji)),
)
.toList();
}
});
}
@@ -1427,6 +1483,7 @@ class _ReactionEmojiPickerState extends State<_ReactionEmojiPicker> {
void dispose() {
_searchCtrl.dispose();
_searchFocus.dispose();
_scrolling.dispose();
super.dispose();
}
@@ -1449,25 +1506,31 @@ class _ReactionEmojiPickerState extends State<_ReactionEmojiPicker> {
)
: _results.isEmpty
? const SizedBox.shrink()
: GridView.builder(
padding: const EdgeInsets.fromLTRB(8, 2, 8, 10),
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
addAutomaticKeepAlives: false,
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 40,
mainAxisSpacing: 2,
crossAxisSpacing: 2,
),
itemCount: _results.length,
itemBuilder: (context, i) {
final emoji = _results[i];
return _EmojiCell(
emoji: emoji,
onTap: () => widget.onPick(emoji),
);
},
: LottieScrollScope(
isScrolling: _scrolling,
child: NotificationListener<ScrollNotification>(
onNotification: _onScrollNotification,
child: GridView.builder(
padding: const EdgeInsets.fromLTRB(8, 2, 8, 10),
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
addAutomaticKeepAlives: false,
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 48,
mainAxisSpacing: 2,
crossAxisSpacing: 2,
),
itemCount: _results.length,
itemBuilder: (context, i) {
final reaction = _results[i];
return _EmojiCell(
reaction: reaction,
onTap: () => widget.onPick(reaction.emoji),
);
},
),
),
),
),
],
@@ -1531,16 +1594,41 @@ class _ReactionEmojiPickerState extends State<_ReactionEmojiPicker> {
}
class _EmojiCell extends StatelessWidget {
final String emoji;
final ReactionEmoji reaction;
final VoidCallback onTap;
const _EmojiCell({required this.emoji, required this.onTap});
const _EmojiCell({required this.reaction, required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Center(child: Text(emoji, style: const TextStyle(fontSize: 22))),
child: Center(child: _ReactionGlyph(reaction: reaction, size: 34)),
);
}
}
class _ReactionGlyph extends StatelessWidget {
final ReactionEmoji reaction;
final double size;
const _ReactionGlyph({required this.reaction, required this.size});
@override
Widget build(BuildContext context) {
final anim = reaction.animationUrl;
final still = reaction.staticUrl;
final hasAsset =
(anim != null && anim.isNotEmpty) || (still != null && still.isNotEmpty);
if (!hasAsset) {
return Center(
child: Text(reaction.emoji, style: TextStyle(fontSize: size * 0.9)),
);
}
return LottieImage(
lottieUrl: anim,
url: still,
size: size,
memCacheWidth: (size * 3).round(),
);
}
}
+34 -25
View File
@@ -128,6 +128,7 @@ class MessageBubble extends StatelessWidget {
final void Function(String messageId)? onReplyTap;
final void Function(int senderId)? onAvatarTap;
final void Function(StickerAttachment sticker)? onStickerTap;
final void Function(String emoji)? onReactionTap;
final String? peerName;
final String? peerAvatarUrl;
@@ -146,6 +147,7 @@ class MessageBubble extends StatelessWidget {
this.onReplyTap,
this.onAvatarTap,
this.onStickerTap,
this.onReactionTap,
this.peerName,
this.peerAvatarUrl,
});
@@ -855,35 +857,42 @@ class MessageBubble extends StatelessWidget {
);
}
chips.add(
Container(
padding: EdgeInsets.fromLTRB(7, 2, avatar != null ? 3 : 7, 2),
decoration: BoxDecoration(
color: isYours
? cs.primary.withValues(alpha: 0.22)
: _reactionChipBg,
borderRadius: _reactionChipRadius,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(c.reaction, style: const TextStyle(fontSize: 13)),
if (c.count > 1) ...[
const SizedBox(width: 3),
Text(
c.count.toString(),
style: TextStyle(
color: isYours ? cs.primary : cs.onSurfaceVariant,
fontSize: 11,
fontWeight: FontWeight.w600,
),
Widget chip = Container(
padding: EdgeInsets.fromLTRB(7, 2, avatar != null ? 3 : 7, 2),
decoration: BoxDecoration(
color: isYours ? cs.primary.withValues(alpha: 0.22) : _reactionChipBg,
borderRadius: _reactionChipRadius,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(c.reaction, style: const TextStyle(fontSize: 13)),
if (c.count > 1) ...[
const SizedBox(width: 3),
Text(
c.count.toString(),
style: TextStyle(
color: isYours ? cs.primary : cs.onSurfaceVariant,
fontSize: 11,
fontWeight: FontWeight.w600,
),
],
if (avatar != null) ...[const SizedBox(width: 5), avatar],
),
],
),
if (avatar != null) ...[const SizedBox(width: 5), avatar],
],
),
);
final onTap = onReactionTap;
if (onTap != null) {
chip = GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onTap(c.reaction),
child: chip,
);
}
chips.add(chip);
}
return chips;
}
-48
View File
@@ -1,48 +0,0 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'sticker_lottie.dart';
class StickerImage extends StatelessWidget {
final String? url;
final String? lottieUrl;
final double? size;
final int? memCacheWidth;
const StickerImage({
super.key,
this.url,
this.lottieUrl,
this.size,
this.memCacheWidth,
});
@override
Widget build(BuildContext context) {
if (lottieUrl != null && lottieUrl!.isNotEmpty) {
return StickerLottie(
lottieUrl: lottieUrl!,
fallbackUrl: url,
size: size,
memCacheWidth: memCacheWidth,
);
}
return _static();
}
Widget _static() {
final src = url ?? '';
final blank = SizedBox(width: size, height: size);
if (src.isEmpty) return blank;
return CachedNetworkImage(
imageUrl: src,
width: size,
height: size,
fit: BoxFit.contain,
memCacheWidth: memCacheWidth,
fadeInDuration: const Duration(milliseconds: 120),
placeholder: (_, _) => blank,
errorWidget: (_, _, _) => blank,
);
}
}
+2 -2
View File
@@ -8,7 +8,7 @@ import '../../models/sticker.dart';
import '../screens/chats/chat_list_screen.dart';
import 'custom_notification.dart';
import 'small_spinner.dart';
import 'sticker_image.dart';
import 'lottie_image.dart';
import 'sticker_peek.dart';
enum _PackAction { forward, copyLink }
@@ -278,7 +278,7 @@ class _StickerPackSheetState extends State<_StickerPackSheet> {
tags: item.tags,
child: Padding(
padding: const EdgeInsets.all(6),
child: StickerImage(
child: LottieImage(
url: item.url,
lottieUrl: item.lottieUrl,
memCacheWidth: 220,
+4 -5
View File
@@ -8,8 +8,7 @@ import '../../core/utils/emoji_keyword_index.dart';
import '../../main.dart' show stickersModule;
import '../../models/sticker.dart';
import 'small_spinner.dart';
import 'sticker_image.dart';
import 'sticker_lottie.dart';
import 'lottie_image.dart';
import 'sticker_peek.dart';
class _DragScrollBehavior extends MaterialScrollBehavior {
@@ -327,7 +326,7 @@ class _StickerPanelState extends State<StickerPanel>
}
Widget _buildContent(ColorScheme cs, int columns, double cell) {
return StickerScrollScope(
return LottieScrollScope(
isScrolling: _scrolling,
child: StickerPeekScope(
child: NotificationListener<ScrollNotification>(
@@ -456,7 +455,7 @@ class _StickerPanelState extends State<StickerPanel>
onTap: () => widget.onStickerTap(item),
child: Padding(
padding: const EdgeInsets.all(6),
child: StickerImage(
child: LottieImage(
url: item.url,
lottieUrl: item.lottieUrl,
memCacheWidth: 220,
@@ -572,7 +571,7 @@ class _StickerSectionState extends State<_StickerSection> {
onTap: () => widget.onTap(item),
child: Padding(
padding: const EdgeInsets.all(6),
child: StickerImage(
child: LottieImage(
url: item.url,
lottieUrl: item.lottieUrl,
memCacheWidth: 220,
+2 -2
View File
@@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../core/utils/haptics.dart';
import 'sticker_image.dart';
import 'lottie_image.dart';
class _PeekData {
final String? url;
@@ -235,7 +235,7 @@ class _PeekOverlay extends StatelessWidget {
SizedBox(
width: previewSize,
height: previewSize,
child: StickerImage(
child: LottieImage(
url: d.url,
lottieUrl: d.lottieUrl,
size: previewSize,