Files
pyramid/lib/features/chat/message/message_actions.dart
T
Bernd Steckmeister 5bf45a9366 refactor: Gott-Datei message_group.dart in 7 Dateien aufgeteilt
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>
2026-07-03 10:58:46 +02:00

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),
),
),
),
),
);
}
}