Files
pyramid/lib/features/chat/message/message_reactions.dart
T
Bernd SteckmeisterandClaude Opus 5.5 ae6f651f4c feat: federnde Übergänge und Animationen, Entwürfe je Raum
- Handy: Chat gleitet federnd über die Chatliste (Liste bleibt aufgebaut,
  Scrollposition erhalten), beim Zurück wieder hinaus; Liste schon während
  des Hinausgleitens bedienbar. Mitgliederleiste gleitet herein.
- Neue/gesendete Nachrichten ploppen weich auf (nur live, nicht beim Öffnen
  oder Nachladen), Reaktionen ploppen auf und hüpfen beim Hochzählen,
  Ungelesen-Zähler hüpft, Scroll-nach-unten-Knopf federt (Handy größer),
  Antwort-Leiste klappt federnd auf/zu, Senden-Pfeil ploppt, Vollansicht
  zoomt auf. Alles skaliert mit Einstellung „Animationen“ (0 % = aus).
- Fix: Beim Raumwechsel blieben Antwort-Ziel, Auswahl und getippter Text am
  neuen Raum hängen (Antwort auf Raum A konnte in Raum B rausgehen). Jetzt
  eigenes Eingabefeld je Raum mit Entwurf im Speicher.
- Antwort-Leiste: größeres Schließen-X auf dem Handy.
- Tests: test/spring_motion_test.dart, UI-Durchlauf mit Übergängen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 22:07:17 +02:00

191 lines
5.6 KiB
Dart

part of '../message_group.dart';
class _ReactionsRow extends StatelessWidget {
final Event event;
final Timeline timeline;
final Room? room;
final String currentUserId;
final PyramidTheme pt;
const _ReactionsRow({
required this.event,
required this.timeline,
required this.currentUserId,
required this.pt,
this.room,
});
@override
Widget build(BuildContext context) {
final reactionEvents = event.aggregatedEvents(timeline, RelationshipTypes.reaction);
if (reactionEvents.isEmpty) return const SizedBox.shrink();
// Group by emoji key → list of senders
final Map<String, List<String>> grouped = {};
final newest = <String, DateTime>{};
for (final r in reactionEvents) {
final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?;
if (key == null || key.isEmpty) continue;
(grouped[key] ??= []).add(r.senderId);
final ts = r.originServerTs;
if (newest[key] == null || ts.isAfter(newest[key]!)) newest[key] = ts;
}
final now = DateTime.now();
if (grouped.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 4, bottom: 2),
child: Wrap(
spacing: 4,
runSpacing: 4,
children: grouped.entries.map((e) {
final myReaction = e.value.contains(currentUserId);
return _ReactionChip(
key: ValueKey(e.key),
// Gerade erst vergeben → federnd aufploppen.
fresh: now.difference(newest[e.key]!) < const Duration(seconds: 4),
emoji: e.key,
count: e.value.length,
mine: myReaction,
pt: pt,
onTap: () {
if (myReaction) {
// Find own reaction event to redact
for (final r in reactionEvents) {
final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?;
if (key == e.key && r.senderId == currentUserId) {
r.redactEvent().catchError((_) => null);
break;
}
}
} else {
room?.sendReaction(event.eventId, e.key).catchError((_) => '');
ReactionService.recordUsed(e.key);
}
},
);
}).toList(),
),
);
}
}
class _ReactionChip extends StatefulWidget {
final String emoji;
final int count;
final bool mine;
final PyramidTheme pt;
final VoidCallback onTap;
final bool fresh;
const _ReactionChip({
super.key,
this.fresh = false,
required this.emoji,
required this.count,
required this.mine,
required this.pt,
required this.onTap,
});
@override
State<_ReactionChip> createState() => _ReactionChipState();
}
class _ReactionChipState extends State<_ReactionChip> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return PopIn(
play: widget.fresh,
fromScale: 0.3,
offsetY: 0,
alignment: Alignment.center,
milliseconds: 520,
child: SpringBump(
value: widget.count,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: SpringPress(
pressedScale: 0.85,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: widget.mine
? pt.accentSoft
: (_hovered ? pt.bgHover : pt.bg2),
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(
color: widget.mine ? pt.accent : (_hovered ? pt.borderStrong : pt.border),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(widget.emoji, style: const TextStyle(fontSize: 13)),
const SizedBox(width: 4),
Text(
'${widget.count}',
style: TextStyle(
color: widget.mine ? pt.accent : pt.fgMuted,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
),
),
),
);
}
}
class _QuickReactionBtn extends StatefulWidget {
final String emoji;
final PyramidTheme pt;
final VoidCallback? onTap;
const _QuickReactionBtn({required this.emoji, required this.pt, this.onTap});
@override
State<_QuickReactionBtn> createState() => _QuickReactionBtnState();
}
class _QuickReactionBtnState extends State<_QuickReactionBtn> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
width: 28,
height: 28,
decoration: BoxDecoration(
color: _hovered ? widget.pt.bgHover : Colors.transparent,
borderRadius: BorderRadius.circular(widget.pt.rSm),
),
child: Center(
child: Text(widget.emoji, style: const TextStyle(fontSize: 15)),
),
),
),
);
}
}