5bf45a9366
Gleiche Technik wie bei settings_modal.dart: reine Verschiebung per Dart part/part-of, keine Logikänderung. message_group.dart schrumpft von 2775 auf 30 Zeilen (nur noch Bibliothekskopf). Verifiziert per automatisiertem Blockvergleich gegen das Original und flutter analyze (Baseline unverändert). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
283 lines
8.9 KiB
Dart
283 lines
8.9 KiB
Dart
part of '../message_group.dart';
|
|
|
|
class _HoverActions extends StatefulWidget {
|
|
final PyramidTheme pt;
|
|
final Event event;
|
|
final List<Event> events;
|
|
final Room? room;
|
|
final String currentUserId;
|
|
final VoidCallback? onReply;
|
|
final ValueChanged<bool>? onOverlayStateChanged;
|
|
|
|
const _HoverActions({
|
|
required this.pt,
|
|
required this.event,
|
|
required this.events,
|
|
required this.currentUserId,
|
|
this.room,
|
|
this.onReply,
|
|
this.onOverlayStateChanged,
|
|
});
|
|
|
|
@override
|
|
State<_HoverActions> createState() => _HoverActionsState();
|
|
}
|
|
|
|
class _HoverActionsState extends State<_HoverActions> {
|
|
OverlayEntry? _reactionOverlay;
|
|
List<String> _quickReactions = ReactionService.quickReactions;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
ReactionService.load().then((_) {
|
|
if (mounted) setState(() => _quickReactions = ReactionService.quickReactions);
|
|
});
|
|
}
|
|
|
|
void _closeReactionOverlay() {
|
|
_reactionOverlay?.remove();
|
|
_reactionOverlay = null;
|
|
widget.onOverlayStateChanged?.call(false);
|
|
}
|
|
|
|
void _toggleReactionPicker() {
|
|
if (_reactionOverlay != null) {
|
|
_closeReactionOverlay();
|
|
return;
|
|
}
|
|
|
|
final box = context.findRenderObject() as RenderBox?;
|
|
final offset = box?.localToGlobal(Offset.zero) ?? Offset.zero;
|
|
|
|
_reactionOverlay = OverlayEntry(builder: (ctx) {
|
|
final screenSize = MediaQuery.sizeOf(ctx);
|
|
const pickerW = 340.0;
|
|
const pickerH = 380.0;
|
|
double left = (offset.dx - pickerW / 2).clamp(8.0, screenSize.width - pickerW - 8);
|
|
double top = (offset.dy - pickerH - 8).clamp(8.0, screenSize.height - pickerH - 8);
|
|
|
|
return Material(
|
|
type: MaterialType.transparency,
|
|
child: Stack(children: [
|
|
GestureDetector(
|
|
onTap: _closeReactionOverlay,
|
|
behavior: HitTestBehavior.translucent,
|
|
child: const SizedBox.expand(),
|
|
),
|
|
Positioned(
|
|
left: left,
|
|
top: top,
|
|
child: EmojiPicker(
|
|
onEmojiSelected: (emoji) {
|
|
_closeReactionOverlay();
|
|
widget.room?.sendReaction(widget.event.eventId, emoji)
|
|
.catchError((_) => '');
|
|
ReactionService.recordUsed(emoji).then((_) {
|
|
if (mounted) setState(() => _quickReactions = ReactionService.quickReactions);
|
|
});
|
|
},
|
|
onClose: _closeReactionOverlay,
|
|
),
|
|
),
|
|
]),
|
|
);
|
|
});
|
|
widget.onOverlayStateChanged?.call(true);
|
|
Overlay.of(context).insert(_reactionOverlay!);
|
|
}
|
|
|
|
void _showMoreMenu() {
|
|
final box = context.findRenderObject() as RenderBox?;
|
|
final offset = box?.localToGlobal(Offset.zero) ?? Offset.zero;
|
|
final size = box?.size ?? Size.zero;
|
|
|
|
final isOwnMessage = widget.event.senderId == widget.currentUserId;
|
|
final text = widget.event.body;
|
|
|
|
// Find any image or sticker event in the group that can be saved as a sticker.
|
|
final imageEvent = widget.events.firstWhereOrNull(
|
|
(e) => e.messageType == 'm.image' || e.type == EventTypes.Sticker,
|
|
);
|
|
|
|
// Capture messenger synchronously before showMenu's async gap so the
|
|
// snackbar can be shown even if the widget is unmounted by then.
|
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
|
|
|
showMenu<String>(
|
|
context: context,
|
|
position: RelativeRect.fromLTRB(
|
|
offset.dx, offset.dy - size.height,
|
|
offset.dx + size.width, offset.dy,
|
|
),
|
|
items: [
|
|
PopupMenuItem(value: 'copy', child: Row(children: [
|
|
Icon(Icons.copy_rounded, size: 16, color: widget.pt.fgMuted),
|
|
const SizedBox(width: 10),
|
|
Text('Kopieren', style: TextStyle(color: widget.pt.fg, fontSize: 14)),
|
|
])),
|
|
if (imageEvent != null)
|
|
PopupMenuItem(value: 'save_sticker', child: Row(children: [
|
|
Icon(Icons.sticky_note_2_outlined, size: 16, color: widget.pt.fgMuted),
|
|
const SizedBox(width: 10),
|
|
Text('Als Sticker speichern', style: TextStyle(color: widget.pt.fg, fontSize: 14)),
|
|
])),
|
|
if (isOwnMessage)
|
|
PopupMenuItem(value: 'delete', child: Row(children: [
|
|
Icon(Icons.delete_outline_rounded, size: 16, color: widget.pt.danger),
|
|
const SizedBox(width: 10),
|
|
Text('Löschen', style: TextStyle(color: widget.pt.danger, fontSize: 14)),
|
|
])),
|
|
PopupMenuItem(value: 'pin', child: Row(children: [
|
|
Icon(Icons.push_pin_outlined, size: 16, color: widget.pt.fgMuted),
|
|
const SizedBox(width: 10),
|
|
Text('Anpinnen', style: TextStyle(color: widget.pt.fg, fontSize: 14)),
|
|
])),
|
|
],
|
|
).then((value) async {
|
|
if (value == 'copy') {
|
|
Clipboard.setData(ClipboardData(text: text));
|
|
} else if (value == 'save_sticker' && imageEvent != null) {
|
|
final ok = await GifFavoriteService.addImageAsStickerFavorite(imageEvent);
|
|
messenger?.showSnackBar(SnackBar(
|
|
content: Text(ok ? 'Als Sticker gespeichert' : 'Fehler beim Speichern'),
|
|
duration: const Duration(seconds: 2),
|
|
));
|
|
} else if (value == 'delete') {
|
|
widget.event.redactEvent(reason: 'Vom Nutzer gelöscht')
|
|
.catchError((_) => null);
|
|
} else if (value == 'pin') {
|
|
final room = widget.room;
|
|
if (room == null) return;
|
|
final pinned = List<String>.from(
|
|
room.getState(EventTypes.RoomPinnedEvents)?.content['pinned'] as List? ?? [],
|
|
);
|
|
final eventId = widget.event.eventId;
|
|
if (!pinned.contains(eventId)) pinned.add(eventId);
|
|
room.setPinnedEvents(pinned).catchError((_) => '');
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
if (_reactionOverlay != null) {
|
|
_reactionOverlay!.remove();
|
|
_reactionOverlay = null;
|
|
widget.onOverlayStateChanged?.call(false);
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final pt = widget.pt;
|
|
return Container(
|
|
padding: const EdgeInsets.all(2),
|
|
decoration: BoxDecoration(
|
|
color: pt.bg0,
|
|
borderRadius: BorderRadius.circular(pt.rBase),
|
|
border: Border.all(color: pt.border),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withAlpha(100),
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
children: [
|
|
// Quick reactions
|
|
..._quickReactions.map((emoji) => _QuickReactionBtn(
|
|
emoji: emoji,
|
|
pt: pt,
|
|
onTap: () {
|
|
widget.room?.sendReaction(widget.event.eventId, emoji)
|
|
.catchError((_) => '');
|
|
ReactionService.recordUsed(emoji).then((_) {
|
|
if (mounted) setState(() => _quickReactions = ReactionService.quickReactions);
|
|
});
|
|
},
|
|
)),
|
|
Container(width: 1, height: 18, color: pt.border, margin: const EdgeInsets.symmetric(horizontal: 2)),
|
|
_ActionBtn(
|
|
icon: Icons.add_reaction_outlined,
|
|
tooltip: 'Reaktion',
|
|
pt: pt,
|
|
accent: true,
|
|
onTap: _toggleReactionPicker,
|
|
),
|
|
_ActionBtn(
|
|
icon: Icons.reply_rounded,
|
|
tooltip: 'Antworten',
|
|
pt: pt,
|
|
onTap: widget.onReply,
|
|
),
|
|
_ActionBtn(
|
|
icon: Icons.more_horiz_rounded,
|
|
tooltip: 'Mehr',
|
|
pt: pt,
|
|
onTap: _showMoreMenu,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ActionBtn extends StatefulWidget {
|
|
final IconData icon;
|
|
final String tooltip;
|
|
final PyramidTheme pt;
|
|
final bool accent;
|
|
final VoidCallback? onTap;
|
|
|
|
const _ActionBtn({
|
|
required this.icon,
|
|
required this.tooltip,
|
|
required this.pt,
|
|
this.accent = false,
|
|
this.onTap,
|
|
});
|
|
|
|
@override
|
|
State<_ActionBtn> createState() => _ActionBtnState();
|
|
}
|
|
|
|
class _ActionBtnState extends State<_ActionBtn> {
|
|
bool _hovered = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final color = _hovered
|
|
? (widget.accent ? widget.pt.accent : widget.pt.fg)
|
|
: widget.pt.fgMuted;
|
|
|
|
return MouseRegion(
|
|
cursor: SystemMouseCursors.click,
|
|
onEnter: (_) => setState(() => _hovered = true),
|
|
onExit: (_) => setState(() => _hovered = false),
|
|
child: GestureDetector(
|
|
onTap: widget.onTap,
|
|
child: Tooltip(
|
|
message: widget.tooltip,
|
|
waitDuration: const Duration(milliseconds: 400),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 120),
|
|
width: 32,
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
color: _hovered ? widget.pt.bgHover : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(widget.pt.rSm),
|
|
),
|
|
child: Center(
|
|
child: Icon(widget.icon, size: 16, color: color),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|