feat: реакции, так еще ебать и анимированные!!!!!! правда лагает пизда я это потом пофиксю мб как в тг сделаю
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user