feat: эмодзи. Пока только фронтенд. issue #45
This commit is contained in:
@@ -32,6 +32,18 @@ class EmojiKeywordIndex {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<String> get all =>
|
||||||
|
List.unmodifiable(_entries.map((e) => e.emoji));
|
||||||
|
|
||||||
|
List<String> search(String query) {
|
||||||
|
final targets = resolve(query);
|
||||||
|
if (targets.isEmpty) return const [];
|
||||||
|
return [
|
||||||
|
for (final entry in _entries)
|
||||||
|
if (targets.contains(entry.emoji)) entry.emoji,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
static String normalize(String emoji) =>
|
static String normalize(String emoji) =>
|
||||||
emoji.replaceAll(_variationSelector, '');
|
emoji.replaceAll(_variationSelector, '');
|
||||||
|
|
||||||
|
|||||||
@@ -264,6 +264,63 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
return notifier;
|
return notifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _reactToMessage(CachedMessage message, String emoji) {
|
||||||
|
final notifier = _reactionNotifierFor(message);
|
||||||
|
notifier.value = _applyLocalReaction(notifier.value, emoji);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? _applyLocalReaction(
|
||||||
|
Map<String, dynamic>? current,
|
||||||
|
String emoji,
|
||||||
|
) {
|
||||||
|
final counters = <String, int>{};
|
||||||
|
final order = <String>[];
|
||||||
|
final rawCounters = current?['counters'];
|
||||||
|
if (rawCounters is List) {
|
||||||
|
for (final c in rawCounters) {
|
||||||
|
if (c is! Map) continue;
|
||||||
|
final r = c['reaction']?.toString();
|
||||||
|
if (r == null || r.isEmpty) continue;
|
||||||
|
final n = c['count'];
|
||||||
|
counters[r] = n is int ? n : 0;
|
||||||
|
order.add(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void decrement(String key) {
|
||||||
|
final next = (counters[key] ?? 1) - 1;
|
||||||
|
if (next <= 0) {
|
||||||
|
counters.remove(key);
|
||||||
|
order.remove(key);
|
||||||
|
} else {
|
||||||
|
counters[key] = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final prev = current?['yourReaction']?.toString();
|
||||||
|
String? your;
|
||||||
|
if (prev == emoji) {
|
||||||
|
decrement(emoji);
|
||||||
|
your = null;
|
||||||
|
} else {
|
||||||
|
if (prev != null && prev.isNotEmpty) decrement(prev);
|
||||||
|
if (!counters.containsKey(emoji)) order.add(emoji);
|
||||||
|
counters[emoji] = (counters[emoji] ?? 0) + 1;
|
||||||
|
your = emoji;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (counters.isEmpty) return null;
|
||||||
|
final total = counters.values.fold<int>(0, (a, b) => a + b);
|
||||||
|
return {
|
||||||
|
'counters': [
|
||||||
|
for (final key in order)
|
||||||
|
{'reaction': key, 'count': counters[key]},
|
||||||
|
],
|
||||||
|
'yourReaction': ?your,
|
||||||
|
'totalCount': total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
void _pruneReactionNotifiers() {
|
void _pruneReactionNotifiers() {
|
||||||
final liveIds = _messages.map((m) => m.id).toSet();
|
final liveIds = _messages.map((m) => m.id).toSet();
|
||||||
final dead = _reactionNotifiers.keys
|
final dead = _reactionNotifiers.keys
|
||||||
@@ -4190,6 +4247,10 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
reasonId,
|
reasonId,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
onReact: message.isControl
|
||||||
|
? null
|
||||||
|
: (emoji) => _reactToMessage(message, emoji),
|
||||||
|
reactions: _reactionNotifierFor(message),
|
||||||
child: bubble,
|
child: bubble,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -5473,6 +5534,8 @@ class _SelectableMessageRow extends StatefulWidget {
|
|||||||
final bool Function() isPinned;
|
final bool Function() isPinned;
|
||||||
final Future<List<({int id, String title})>> Function()? loadReportReasons;
|
final Future<List<({int id, String title})>> Function()? loadReportReasons;
|
||||||
final Future<bool> Function(int reasonId)? onReport;
|
final Future<bool> Function(int reasonId)? onReport;
|
||||||
|
final void Function(String emoji)? onReact;
|
||||||
|
final ValueListenable<Map<String, dynamic>?>? reactions;
|
||||||
|
|
||||||
const _SelectableMessageRow({
|
const _SelectableMessageRow({
|
||||||
required this.child,
|
required this.child,
|
||||||
@@ -5492,6 +5555,8 @@ class _SelectableMessageRow extends StatefulWidget {
|
|||||||
required this.isPinned,
|
required this.isPinned,
|
||||||
this.loadReportReasons,
|
this.loadReportReasons,
|
||||||
this.onReport,
|
this.onReport,
|
||||||
|
this.onReact,
|
||||||
|
this.reactions,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -5547,6 +5612,8 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> {
|
|||||||
onMarkUnread: widget.onMarkUnread,
|
onMarkUnread: widget.onMarkUnread,
|
||||||
onPin: widget.onPin,
|
onPin: widget.onPin,
|
||||||
isPinned: _isPinnedNow(),
|
isPinned: _isPinnedNow(),
|
||||||
|
onReact: widget.onReact,
|
||||||
|
selectedReaction: widget.reactions?.value?['yourReaction']?.toString(),
|
||||||
onDispose: controller.dispose,
|
onDispose: controller.dispose,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
import '../../core/config/app_message_actions_style.dart';
|
import '../../core/config/app_message_actions_style.dart';
|
||||||
|
import '../../core/utils/emoji_keyword_index.dart';
|
||||||
import '../../core/utils/format.dart';
|
import '../../core/utils/format.dart';
|
||||||
import '../../core/utils/haptics.dart';
|
import '../../core/utils/haptics.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
@@ -87,6 +88,9 @@ void showMessageActions({
|
|||||||
VoidCallback? onMarkUnread,
|
VoidCallback? onMarkUnread,
|
||||||
VoidCallback? onPin,
|
VoidCallback? onPin,
|
||||||
bool isPinned = false,
|
bool isPinned = false,
|
||||||
|
void Function(String emoji)? onReact,
|
||||||
|
String? selectedReaction,
|
||||||
|
List<String> quickReactions = const ['👍', '❤️', '🔥', '😂', '😮', '😢'],
|
||||||
MessageActionsInteraction interaction =
|
MessageActionsInteraction interaction =
|
||||||
MessageActionsInteraction.dragAndRelease,
|
MessageActionsInteraction.dragAndRelease,
|
||||||
}) {
|
}) {
|
||||||
@@ -112,6 +116,9 @@ void showMessageActions({
|
|||||||
onMarkUnread: onMarkUnread,
|
onMarkUnread: onMarkUnread,
|
||||||
onPin: onPin,
|
onPin: onPin,
|
||||||
isPinned: isPinned,
|
isPinned: isPinned,
|
||||||
|
onReact: onReact,
|
||||||
|
selectedReaction: selectedReaction,
|
||||||
|
quickReactions: quickReactions,
|
||||||
onDismiss: () {
|
onDismiss: () {
|
||||||
if (entry.mounted) entry.remove();
|
if (entry.mounted) entry.remove();
|
||||||
onDispose();
|
onDispose();
|
||||||
@@ -141,6 +148,9 @@ class _MessageActionsLayer extends StatefulWidget {
|
|||||||
final VoidCallback? onMarkUnread;
|
final VoidCallback? onMarkUnread;
|
||||||
final VoidCallback? onPin;
|
final VoidCallback? onPin;
|
||||||
final bool isPinned;
|
final bool isPinned;
|
||||||
|
final void Function(String emoji)? onReact;
|
||||||
|
final String? selectedReaction;
|
||||||
|
final List<String> quickReactions;
|
||||||
|
|
||||||
const _MessageActionsLayer({
|
const _MessageActionsLayer({
|
||||||
required this.snapshot,
|
required this.snapshot,
|
||||||
@@ -162,6 +172,9 @@ class _MessageActionsLayer extends StatefulWidget {
|
|||||||
this.onMarkUnread,
|
this.onMarkUnread,
|
||||||
this.onPin,
|
this.onPin,
|
||||||
this.isPinned = false,
|
this.isPinned = false,
|
||||||
|
this.onReact,
|
||||||
|
this.selectedReaction,
|
||||||
|
this.quickReactions = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -172,6 +185,11 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
|
|||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late final AnimationController _animController;
|
late final AnimationController _animController;
|
||||||
late final Animation<double> _animation;
|
late final Animation<double> _animation;
|
||||||
|
late final AnimationController _expandController;
|
||||||
|
late final Animation<double> _expandAnim;
|
||||||
|
bool _reactionsExpanded = false;
|
||||||
|
bool _reactionsPanelReady = false;
|
||||||
|
Widget? _pickerCache;
|
||||||
bool _closing = false;
|
bool _closing = false;
|
||||||
static const double _radius = 92.0;
|
static const double _radius = 92.0;
|
||||||
static const double _arcSpan = math.pi * 0.62;
|
static const double _arcSpan = math.pi * 0.62;
|
||||||
@@ -199,6 +217,9 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
if (_reactionsEnabled) {
|
||||||
|
EmojiKeywordIndex.instance.ensureLoaded();
|
||||||
|
}
|
||||||
_animController = AnimationController(
|
_animController = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 320),
|
duration: const Duration(milliseconds: 320),
|
||||||
@@ -209,9 +230,34 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
|
|||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
reverseCurve: Curves.easeInCubic,
|
reverseCurve: Curves.easeInCubic,
|
||||||
);
|
);
|
||||||
|
_expandController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 360),
|
||||||
|
reverseDuration: const Duration(milliseconds: 260),
|
||||||
|
);
|
||||||
|
_expandAnim = CurvedAnimation(
|
||||||
|
parent: _expandController,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
reverseCurve: Curves.easeInCubic,
|
||||||
|
);
|
||||||
|
_expandController.addStatusListener(_onExpandStatus);
|
||||||
_animController.forward();
|
_animController.forward();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onExpandStatus(AnimationStatus status) {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (status == AnimationStatus.completed) {
|
||||||
|
if (!_reactionsPanelReady) setState(() => _reactionsPanelReady = true);
|
||||||
|
} else if (status == AnimationStatus.dismissed) {
|
||||||
|
if (_reactionsPanelReady) setState(() => _reactionsPanelReady = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _reactionsEnabled =>
|
||||||
|
widget.onReact != null &&
|
||||||
|
widget.quickReactions.isNotEmpty &&
|
||||||
|
widget.interaction != MessageActionsInteraction.click;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
@@ -385,6 +431,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_animController.dispose();
|
_animController.dispose();
|
||||||
|
_expandController.dispose();
|
||||||
widget.controller.removeListener(_onControllerUpdate);
|
widget.controller.removeListener(_onControllerUpdate);
|
||||||
widget.snapshot?.dispose();
|
widget.snapshot?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -570,12 +617,14 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final size = MediaQuery.sizeOf(context);
|
final size = MediaQuery.sizeOf(context);
|
||||||
final isClick = widget.interaction == MessageActionsInteraction.click;
|
final isClick = widget.interaction == MessageActionsInteraction.click;
|
||||||
|
final showReactions = _reactionsEnabled && !isClick;
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: _animation,
|
animation: Listenable.merge([_animation, _expandController]),
|
||||||
builder: (ctx, _) {
|
builder: (ctx, _) {
|
||||||
final t = _animation.value.clamp(0.0, 1.0);
|
final t = _animation.value.clamp(0.0, 1.0);
|
||||||
final blurSigma = 14.0 * t;
|
final e = showReactions ? _expandAnim.value.clamp(0.0, 1.0) : 0.0;
|
||||||
final bubbleScale = 1.0 + 0.05 * t;
|
final bubbleScale = 1.0 + 0.02 * t;
|
||||||
|
final menuHidden = _showHistory || _showReport || _reactionsExpanded;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: _close,
|
onTap: _close,
|
||||||
@@ -584,14 +633,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
|
|||||||
children: [
|
children: [
|
||||||
if (!isClick) ...[
|
if (!isClick) ...[
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: BackdropFilter(
|
child: ColoredBox(
|
||||||
filter: ui.ImageFilter.blur(
|
color: Colors.black.withValues(alpha: 0.22 * t + 0.28 * e),
|
||||||
sigmaX: blurSigma,
|
|
||||||
sigmaY: blurSigma,
|
|
||||||
),
|
|
||||||
child: ColoredBox(
|
|
||||||
color: Colors.black.withValues(alpha: 0.22 * t),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (widget.snapshot != null)
|
if (widget.snapshot != null)
|
||||||
@@ -600,22 +643,25 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
|
|||||||
top: widget.originRect.top,
|
top: widget.originRect.top,
|
||||||
width: widget.originRect.width,
|
width: widget.originRect.width,
|
||||||
height: widget.originRect.height,
|
height: widget.originRect.height,
|
||||||
child: Transform.scale(
|
child: Opacity(
|
||||||
scale: bubbleScale,
|
opacity: 1.0 - 0.35 * e,
|
||||||
child: RawImage(
|
child: Transform.scale(
|
||||||
image: widget.snapshot,
|
scale: bubbleScale,
|
||||||
width: widget.originRect.width,
|
child: RawImage(
|
||||||
height: widget.originRect.height,
|
image: widget.snapshot,
|
||||||
fit: BoxFit.fill,
|
width: widget.originRect.width,
|
||||||
|
height: widget.originRect.height,
|
||||||
|
fit: BoxFit.fill,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
ignoring: _showHistory || _showReport,
|
ignoring: menuHidden,
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
opacity: (_showHistory || _showReport) ? 0.0 : 1.0,
|
opacity: menuHidden ? 0.0 : 1.0,
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: const Duration(milliseconds: 150),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
@@ -648,6 +694,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (showReactions)
|
||||||
|
Positioned.fill(child: _buildReactionStrip(t, e)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -655,6 +703,313 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _toggleReactionsExpanded() {
|
||||||
|
Haptics.tap();
|
||||||
|
setState(() => _reactionsExpanded = !_reactionsExpanded);
|
||||||
|
if (_reactionsExpanded) {
|
||||||
|
_expandController.forward();
|
||||||
|
} else {
|
||||||
|
_expandController.reverse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onReactionPicked(String emoji) async {
|
||||||
|
final cb = widget.onReact;
|
||||||
|
Haptics.medium();
|
||||||
|
await _close();
|
||||||
|
cb?.call(emoji);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isSelectedReaction(String emoji) {
|
||||||
|
final sel = widget.selectedReaction;
|
||||||
|
if (sel == null) return false;
|
||||||
|
return EmojiKeywordIndex.normalize(sel) ==
|
||||||
|
EmojiKeywordIndex.normalize(emoji);
|
||||||
|
}
|
||||||
|
|
||||||
|
Rect _reactionAnchorRect() {
|
||||||
|
if (_effectiveStyle == MessageActionsStyle.list &&
|
||||||
|
_menuRect != Rect.zero) {
|
||||||
|
return _menuRect;
|
||||||
|
}
|
||||||
|
if (_buttonHitRects.isNotEmpty) {
|
||||||
|
var box = _buttonHitRects.first;
|
||||||
|
for (final r in _buttonHitRects.skip(1)) {
|
||||||
|
box = box.expandToInclude(r);
|
||||||
|
}
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
return widget.originRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildReactionStrip(double t, double e) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
final padding = MediaQuery.paddingOf(context);
|
||||||
|
final keyboardInset = MediaQuery.viewInsetsOf(context).bottom;
|
||||||
|
|
||||||
|
final safeTop = padding.top + 8;
|
||||||
|
final safeBottom = size.height - math.max(padding.bottom, keyboardInset) - 8;
|
||||||
|
|
||||||
|
final quick = widget.quickReactions;
|
||||||
|
const chevronCell = 38.0;
|
||||||
|
const pillPad = 6.0;
|
||||||
|
const pillHeight = 46.0;
|
||||||
|
const gap = 10.0;
|
||||||
|
const maxCell = 36.0;
|
||||||
|
|
||||||
|
double pillWidth = pillPad * 2 + quick.length * maxCell + chevronCell;
|
||||||
|
final maxPillWidth = size.width - 16;
|
||||||
|
double cell = maxCell;
|
||||||
|
if (pillWidth > maxPillWidth) {
|
||||||
|
cell = ((maxPillWidth - pillPad * 2 - chevronCell) / quick.length)
|
||||||
|
.clamp(28.0, maxCell);
|
||||||
|
pillWidth = pillPad * 2 + quick.length * cell + chevronCell;
|
||||||
|
}
|
||||||
|
|
||||||
|
final anchor = _reactionAnchorRect();
|
||||||
|
double pillLeft = anchor.left.clamp(
|
||||||
|
8.0,
|
||||||
|
math.max(8.0, size.width - 8 - pillWidth),
|
||||||
|
);
|
||||||
|
|
||||||
|
final pillAbove = (anchor.top - safeTop) >= pillHeight + gap;
|
||||||
|
double pillTop = pillAbove
|
||||||
|
? anchor.top - gap - pillHeight
|
||||||
|
: anchor.bottom + gap;
|
||||||
|
pillTop = pillTop.clamp(safeTop, math.max(safeTop, safeBottom - pillHeight));
|
||||||
|
final collapsed = Rect.fromLTWH(pillLeft, pillTop, pillWidth, pillHeight);
|
||||||
|
|
||||||
|
final panelWidth = math.min(size.width - 24, 300.0);
|
||||||
|
const desiredHeight = 300.0;
|
||||||
|
double panelLeft = collapsed.left.clamp(
|
||||||
|
8.0,
|
||||||
|
math.max(8.0, size.width - 8 - panelWidth),
|
||||||
|
);
|
||||||
|
double panelTop;
|
||||||
|
double panelHeight;
|
||||||
|
if (pillAbove) {
|
||||||
|
panelTop = collapsed.top;
|
||||||
|
panelHeight = math.min(desiredHeight, safeBottom - panelTop);
|
||||||
|
} else {
|
||||||
|
final panelBottom = collapsed.bottom;
|
||||||
|
panelTop = math.max(safeTop, panelBottom - desiredHeight);
|
||||||
|
panelHeight = panelBottom - panelTop;
|
||||||
|
}
|
||||||
|
final expanded = Rect.fromLTWH(panelLeft, panelTop, panelWidth, panelHeight);
|
||||||
|
|
||||||
|
final morph = Rect.lerp(collapsed, expanded, e)!;
|
||||||
|
final radius = ui.lerpDouble(pillHeight / 2, 20.0, e)!;
|
||||||
|
final entryAlign = pillAbove
|
||||||
|
? Alignment.bottomLeft
|
||||||
|
: Alignment.topLeft;
|
||||||
|
|
||||||
|
return IgnorePointer(
|
||||||
|
ignoring: t < 0.5,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: t,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
if (e < 0.999)
|
||||||
|
_buildCloudTail(collapsed, pillAbove, cs, t, e),
|
||||||
|
Positioned.fromRect(
|
||||||
|
rect: morph,
|
||||||
|
child: Transform.scale(
|
||||||
|
scale: 0.9 + 0.1 * t,
|
||||||
|
alignment: entryAlign,
|
||||||
|
child: _buildReactionSurface(
|
||||||
|
cs,
|
||||||
|
radius,
|
||||||
|
e,
|
||||||
|
cell,
|
||||||
|
quick,
|
||||||
|
expanded.size,
|
||||||
|
pillAbove,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildReactionSurface(
|
||||||
|
ColorScheme cs,
|
||||||
|
double radius,
|
||||||
|
double e,
|
||||||
|
double cell,
|
||||||
|
List<String> quick,
|
||||||
|
Size expandedSize,
|
||||||
|
bool pillAbove,
|
||||||
|
) {
|
||||||
|
final borderRadius = BorderRadius.circular(radius);
|
||||||
|
_pickerCache ??= RepaintBoundary(
|
||||||
|
child: _ReactionEmojiPicker(onPick: _onReactionPicked),
|
||||||
|
);
|
||||||
|
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.4),
|
||||||
|
blurRadius: 18,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () {},
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (_reactionsPanelReady)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
top: pillAbove ? 0 : null,
|
||||||
|
bottom: pillAbove ? null : 0,
|
||||||
|
width: expandedSize.width,
|
||||||
|
height: expandedSize.height,
|
||||||
|
child: TweenAnimationBuilder<double>(
|
||||||
|
tween: Tween(begin: 0.0, end: 1.0),
|
||||||
|
duration: const Duration(milliseconds: 140),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
child: _pickerCache,
|
||||||
|
builder: (_, value, child) =>
|
||||||
|
Opacity(opacity: value, child: child),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!_reactionsPanelReady)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: pillAbove ? 0 : null,
|
||||||
|
bottom: pillAbove ? null : 0,
|
||||||
|
height: 46,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: (1.0 - e).clamp(0.0, 1.0),
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: e > 0.05,
|
||||||
|
child: _buildQuickRow(cs, cell, quick),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildQuickRow(ColorScheme cs, double cell, List<String> quick) {
|
||||||
|
return Center(
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
for (final emoji in quick) _quickEmoji(cs, emoji, cell),
|
||||||
|
_chevronButton(cs),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _quickEmoji(ColorScheme cs, String emoji, double cell) {
|
||||||
|
final selected = _isSelectedReaction(emoji);
|
||||||
|
return SizedBox(
|
||||||
|
width: cell,
|
||||||
|
height: cell,
|
||||||
|
child: Material(
|
||||||
|
color: selected
|
||||||
|
? cs.primary.withValues(alpha: 0.22)
|
||||||
|
: Colors.transparent,
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
child: InkWell(
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
onTap: () => _onReactionPicked(emoji),
|
||||||
|
child: Center(
|
||||||
|
child: Text(emoji, style: TextStyle(fontSize: cell * 0.6)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _chevronButton(ColorScheme cs) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: Material(
|
||||||
|
color: cs.surfaceContainerHighest,
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
child: InkWell(
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
onTap: _toggleReactionsExpanded,
|
||||||
|
child: Icon(
|
||||||
|
Symbols.keyboard_arrow_down,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCloudTail(
|
||||||
|
Rect pill,
|
||||||
|
bool above,
|
||||||
|
ColorScheme cs,
|
||||||
|
double t,
|
||||||
|
double e,
|
||||||
|
) {
|
||||||
|
final fade = (t * (1.0 - e * 1.4)).clamp(0.0, 1.0);
|
||||||
|
final baseX = pill.right - 22;
|
||||||
|
final edgeY = above ? pill.bottom - 2 : pill.top + 2;
|
||||||
|
final dir = above ? 1.0 : -1.0;
|
||||||
|
final big = Offset(baseX, edgeY + dir * 6);
|
||||||
|
final small = Offset(baseX + 8, edgeY + dir * 17);
|
||||||
|
|
||||||
|
return IgnorePointer(
|
||||||
|
child: Opacity(
|
||||||
|
opacity: fade,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
_tailCircle(big, 8.0, cs),
|
||||||
|
_tailCircle(small, 5.0, cs),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _tailCircle(Offset c, double r, ColorScheme cs) {
|
||||||
|
return Positioned(
|
||||||
|
left: c.dx - r,
|
||||||
|
top: c.dy - r,
|
||||||
|
width: r * 2,
|
||||||
|
height: r * 2,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.28),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildAnchoredPanel({required String title, required Widget body}) {
|
Widget _buildAnchoredPanel({required String title, required Widget body}) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
final size = MediaQuery.sizeOf(context);
|
final size = MediaQuery.sizeOf(context);
|
||||||
@@ -1019,6 +1374,172 @@ class _Action {
|
|||||||
const _Action(this.icon, this.label, this.onTap, {this.destructive = false});
|
const _Action(this.icon, this.label, this.onTap, {this.destructive = false});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ReactionEmojiPicker extends StatefulWidget {
|
||||||
|
final ValueChanged<String> onPick;
|
||||||
|
const _ReactionEmojiPicker({required this.onPick});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ReactionEmojiPicker> createState() => _ReactionEmojiPickerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReactionEmojiPickerState extends State<_ReactionEmojiPicker> {
|
||||||
|
final TextEditingController _searchCtrl = TextEditingController();
|
||||||
|
final FocusNode _searchFocus = FocusNode();
|
||||||
|
List<String> _all = const [];
|
||||||
|
List<String> _results = const [];
|
||||||
|
String _query = '';
|
||||||
|
bool _loaded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
await EmojiKeywordIndex.instance.ensureLoaded();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_all = EmojiKeywordIndex.instance.all;
|
||||||
|
_results = _all;
|
||||||
|
_loaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onQueryChanged(String value) {
|
||||||
|
final q = value.trim();
|
||||||
|
setState(() {
|
||||||
|
_query = q;
|
||||||
|
_results = q.isEmpty ? _all : EmojiKeywordIndex.instance.search(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearSearch() {
|
||||||
|
_searchCtrl.clear();
|
||||||
|
_onQueryChanged('');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchCtrl.dispose();
|
||||||
|
_searchFocus.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_buildSearchField(cs),
|
||||||
|
Expanded(
|
||||||
|
child: !_loaded
|
||||||
|
? const Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2.4),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSearchField(ColorScheme cs) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(10, 10, 10, 6),
|
||||||
|
child: Container(
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(21),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Icon(Symbols.search, size: 22, color: cs.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchCtrl,
|
||||||
|
focusNode: _searchFocus,
|
||||||
|
onChanged: _onQueryChanged,
|
||||||
|
textInputAction: TextInputAction.search,
|
||||||
|
cursorColor: cs.primary,
|
||||||
|
style: TextStyle(color: cs.onSurface, fontSize: 15),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isCollapsed: true,
|
||||||
|
border: InputBorder.none,
|
||||||
|
hintText: AppLocalizations.of(context)!.emojiSearchHint,
|
||||||
|
hintStyle: TextStyle(
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_query.isEmpty)
|
||||||
|
const SizedBox(width: 12)
|
||||||
|
else
|
||||||
|
GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: _clearSearch,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
|
child: Icon(
|
||||||
|
Symbols.close,
|
||||||
|
size: 20,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmojiCell extends StatelessWidget {
|
||||||
|
final String emoji;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const _EmojiCell({required this.emoji, 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)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _ListMenuItem extends StatelessWidget {
|
class _ListMenuItem extends StatelessWidget {
|
||||||
final _Action action;
|
final _Action action;
|
||||||
final bool highlighted;
|
final bool highlighted;
|
||||||
|
|||||||
@@ -183,6 +183,7 @@
|
|||||||
"registrationSubtitle": "Add your name and pick an avatar",
|
"registrationSubtitle": "Add your name and pick an avatar",
|
||||||
"registrationChooseAvatar": "Choose an avatar",
|
"registrationChooseAvatar": "Choose an avatar",
|
||||||
"msgActionsCopy": "Copy",
|
"msgActionsCopy": "Copy",
|
||||||
|
"emojiSearchHint": "Search emoji",
|
||||||
"msgActionsEdit": "Edit",
|
"msgActionsEdit": "Edit",
|
||||||
"msgActionsReply": "Reply",
|
"msgActionsReply": "Reply",
|
||||||
"msgActionsForward": "Forward",
|
"msgActionsForward": "Forward",
|
||||||
|
|||||||
@@ -1094,6 +1094,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Copy'**
|
/// **'Copy'**
|
||||||
String get msgActionsCopy;
|
String get msgActionsCopy;
|
||||||
|
|
||||||
|
/// No description provided for @emojiSearchHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Search emoji'**
|
||||||
|
String get emojiSearchHint;
|
||||||
|
|
||||||
/// No description provided for @msgActionsEdit.
|
/// No description provided for @msgActionsEdit.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -524,6 +524,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get msgActionsCopy => 'Copy';
|
String get msgActionsCopy => 'Copy';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emojiSearchHint => 'Search emoji';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get msgActionsEdit => 'Edit';
|
String get msgActionsEdit => 'Edit';
|
||||||
|
|
||||||
|
|||||||
@@ -527,6 +527,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get msgActionsCopy => 'Копировать';
|
String get msgActionsCopy => 'Копировать';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get emojiSearchHint => 'Поиск эмодзи';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get msgActionsEdit => 'Изменить';
|
String get msgActionsEdit => 'Изменить';
|
||||||
|
|
||||||
|
|||||||
@@ -183,6 +183,7 @@
|
|||||||
"registrationSubtitle": "Укажите имя и выберите аватар",
|
"registrationSubtitle": "Укажите имя и выберите аватар",
|
||||||
"registrationChooseAvatar": "Выберите аватар",
|
"registrationChooseAvatar": "Выберите аватар",
|
||||||
"msgActionsCopy": "Копировать",
|
"msgActionsCopy": "Копировать",
|
||||||
|
"emojiSearchHint": "Поиск эмодзи",
|
||||||
"msgActionsEdit": "Изменить",
|
"msgActionsEdit": "Изменить",
|
||||||
"msgActionsReply": "Ответить",
|
"msgActionsReply": "Ответить",
|
||||||
"msgActionsForward": "Переслать",
|
"msgActionsForward": "Переслать",
|
||||||
|
|||||||
Reference in New Issue
Block a user