Files
pyramid/lib/features/chat/message_group.dart
T
Bernd Steckmeister 6400fb639c refactor: toten Code entfernen, doppelte Zeit-Formatierung zusammenführen
message_bubble.dart war vollständig unbenutzt (durch message_group.dart
abgelöst). Die zwei identischen _formatTime-Methoden in message_group.dart
zu einer Top-Level-Funktion zusammengeführt - reine Extraktion, keine
Verhaltensänderung. flutter analyze bleibt sauber.
2026-07-03 10:29:54 +02:00

2776 lines
94 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pyramid/features/chat/media_player.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/media_cache.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/chat/emoji_picker.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:pyramid/features/chat/document_viewer.dart';
import 'package:pyramid/features/chat/media_viewer.dart';
import 'package:pyramid/features/chat/reaction_service.dart';
import 'package:pyramid/utils/gif_favorite_service.dart';
import 'package:pyramid/widgets/mxc_image.dart';
import 'package:url_launcher/url_launcher.dart';
String _formatMessageTime(DateTime t) {
final h = t.hour.toString().padLeft(2, '0');
final m = t.minute.toString().padLeft(2, '0');
return '$h:$m';
}
// Wraps a group of consecutive messages from the same sender
class MessageGroup extends StatefulWidget {
final List<Event> events;
final String currentUserId;
final Room? room;
final Timeline? timeline;
final VoidCallback? onReply;
final Set<String> selectedIds;
final ValueChanged<String>? onToggleSelect;
final bool showStatusIndicator;
/// Event IDs that another member has read up to (their last-read position).
/// A ✓✓ is rendered at any of this group's events contained here.
final Set<String> readEventIds;
/// When non-null and pointing to a non-last event in this group, an inline
/// "Neue Nachrichten" divider is rendered after that event's row.
final String? fullyReadId;
/// Jump-to-message (from search): the row matching [jumpEventId] gets
/// [jumpKey] attached (for ensureVisible) and [highlightEventId] is tinted.
final String? jumpEventId;
final GlobalKey? jumpKey;
final String? highlightEventId;
final bool highlightOn;
const MessageGroup({
super.key,
required this.events,
required this.currentUserId,
this.room,
this.timeline,
this.onReply,
this.selectedIds = const {},
this.onToggleSelect,
this.showStatusIndicator = false,
this.readEventIds = const {},
this.fullyReadId,
this.jumpEventId,
this.jumpKey,
this.highlightEventId,
this.highlightOn = false,
});
@override
State<MessageGroup> createState() => _MessageGroupState();
}
class _MessageGroupState extends State<MessageGroup> {
// Per-message hover state (desktop): which event row the pointer is over, and
// which row currently has its reaction-picker overlay open.
String? _hoveredEventId;
String? _overlayEventId;
double _swipeOffset = 0;
static const _swipeThreshold = 56.0;
bool _swipeTriggered = false;
bool get _isMobile => Platform.isAndroid || Platform.isIOS;
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final firstEvent = widget.events.first;
final sender = firstEvent.senderFromMemoryOrFallback;
final name = sender.calcDisplayname();
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
final color = _colorFromId(firstEvent.senderId);
final time = _formatMessageTime(firstEvent.originServerTs);
final avatarUri = sender.avatarUrl;
// Build a FLAT column of rows — each message at the same indentation level.
// First event row: avatar + name + time + content
// Continuation rows: timestamp-slot + content
// This prevents the nested-Row double-indent bug.
final rows = <Widget>[];
// ── First event row ──────────────────────────────────
// Each message row is INDIVIDUALLY selectable (see _selectableRow) so two
// consecutive messages from the same sender aren't selected as one block.
rows.add(_selectableRow(firstEvent, pt, Padding(
padding: EdgeInsets.only(top: pt.msgGap / 2, left: 20, right: 20, bottom: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Avatar
SizedBox(
width: 38,
child: Column(children: [
const SizedBox(height: 2),
_Avatar(initial: initial, color: color, pt: pt, size: 38, avatarUri: avatarUri),
]),
),
const SizedBox(width: 14),
// Name + time + message
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Text(name, style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(width: 8),
Text(time, style: TextStyle(color: pt.fgDim, fontSize: 11)),
]),
const SizedBox(height: 2),
_MessageBody(
event: firstEvent,
pt: pt,
timeline: widget.timeline,
room: widget.room,
currentUserId: widget.currentUserId,
),
],
),
),
],
),
)));
if (widget.readEventIds.contains(firstEvent.eventId)) rows.add(_ReadMarker(pt: pt));
// Inline unread divider after the first event when it's the fullyRead
// marker and there are more events in this group.
if (widget.fullyReadId == firstEvent.eventId && widget.events.length > 1) {
rows.add(_InlineUnreadDivider(pt: pt));
}
// ── Continuation event rows (siblings, same indentation) ──
for (var j = 1; j < widget.events.length; j++) {
final event = widget.events[j];
rows.add(_selectableRow(event, pt, Padding(
padding: const EdgeInsets.only(top: 8, left: 20, right: 20),
child: _ContinuationMessage(
event: event,
pt: pt,
timeline: widget.timeline,
room: widget.room,
currentUserId: widget.currentUserId,
),
)));
if (widget.readEventIds.contains(event.eventId)) rows.add(_ReadMarker(pt: pt));
// Inline divider after this event if it's the fullyRead and not the last.
if (widget.fullyReadId == event.eventId && j < widget.events.length - 1) {
rows.add(_InlineUnreadDivider(pt: pt));
}
}
// Sending/sent indicator on the newest own message — only when no read ✓✓
// is already shown for it (the ✓✓ implies it was delivered + read).
if (widget.showStatusIndicator &&
firstEvent.senderId == widget.currentUserId &&
widget.room != null &&
!widget.readEventIds.contains(widget.events.last.eventId)) {
rows.add(Padding(
padding: const EdgeInsets.only(right: 24, bottom: 1),
child: Align(
alignment: Alignment.centerRight,
child: _MessageStatus(
lastEvent: widget.events.last,
pt: pt,
),
),
));
}
rows.add(const SizedBox(height: 2));
// Hover highlight + actions are now per-row (see _selectableRow), so the
// group is just the column of rows.
Widget content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: rows,
);
if (!_isMobile) return content;
return GestureDetector(
onHorizontalDragUpdate: (d) {
if (d.delta.dx > 0) return; // Only allow right-to-left swipe for reply
setState(() {
_swipeOffset = (_swipeOffset + d.delta.dx).clamp(-_swipeThreshold, 0.0);
if (_swipeOffset <= -_swipeThreshold && !_swipeTriggered) {
_swipeTriggered = true;
HapticFeedback.mediumImpact();
}
});
},
onHorizontalDragEnd: (_) {
if (_swipeTriggered) widget.onReply?.call();
setState(() { _swipeOffset = 0; _swipeTriggered = false; });
},
child: Stack(
children: [
Transform.translate(
offset: Offset(_swipeOffset, 0),
child: content,
),
if (_swipeOffset < -8)
Positioned(
right: 12,
top: 0,
bottom: 0,
child: Center(
child: AnimatedOpacity(
opacity: (-_swipeOffset / _swipeThreshold).clamp(0.0, 1.0),
duration: const Duration(milliseconds: 60),
child: Icon(Icons.reply_rounded, size: 20, color: pt.fgMuted),
),
),
),
],
),
);
}
// Wraps a single message row so it can be selected independently of the rest
// of the sender's group. Long-press enters selection mode; in selection mode a
// full-row tap overlay toggles this message's selection.
Widget _selectableRow(Event event, PyramidTheme pt, Widget child) {
final selected = widget.selectedIds.contains(event.eventId);
final inSel = widget.selectedIds.isNotEmpty;
final showActions = !_isMobile && !inSel &&
(_hoveredEventId == event.eventId || _overlayEventId == event.eventId);
final isJumpTarget = widget.jumpEventId == event.eventId;
final isHighlighted = widget.highlightEventId == event.eventId;
Widget row = GestureDetector(
key: isJumpTarget ? widget.jumpKey : null,
onLongPress: () => widget.onToggleSelect?.call(event.eventId),
child: Stack(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 100),
color: selected
? pt.accent.withAlpha(40)
: (showActions ? pt.bg2 : Colors.transparent),
child: child,
),
// Jump-from-search highlight — fades out via AnimatedOpacity.
if (isHighlighted)
Positioned.fill(
child: IgnorePointer(
child: AnimatedOpacity(
opacity: widget.highlightOn ? 1.0 : 0.0,
duration: const Duration(milliseconds: 900),
curve: Curves.easeOut,
child: Container(color: pt.accent.withAlpha(48)),
),
),
),
if (selected)
Positioned(
left: 0,
top: 0,
bottom: 0,
child: Container(width: 3, color: pt.accent),
),
if (showActions)
Positioned(
top: 0,
right: 32,
child: _HoverActions(
pt: pt,
event: event,
events: [event],
room: widget.room,
currentUserId: widget.currentUserId,
onReply: widget.onReply,
onOverlayStateChanged: (active) => setState(
() => _overlayEventId = active ? event.eventId : null),
),
),
if (inSel)
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => widget.onToggleSelect?.call(event.eventId),
),
),
],
),
);
if (_isMobile) return row;
// Desktop: per-row hover highlight + actions.
return MouseRegion(
onEnter: (_) => setState(() => _hoveredEventId = event.eventId),
onExit: (_) => setState(() {
if (_hoveredEventId == event.eventId) _hoveredEventId = null;
}),
child: row,
);
}
Color _colorFromId(String id) {
const colors = [
Color(0xFF06B6D4), Color(0xFFA855F7), Color(0xFF10B981),
Color(0xFFF43F5E), Color(0xFF3B82F6), Color(0xFFF59E0B),
Color(0xFFFF6B9D),
];
if (id.isEmpty) return colors[0];
return colors[id.codeUnitAt(0) % colors.length];
}
}
// Shown under a message whose send failed (e.g. offline) — keeps the message
// visible with retry / delete instead of letting it silently disappear.
class _SendFailedRow extends StatelessWidget {
final Event event;
final PyramidTheme pt;
const _SendFailedRow({required this.event, required this.pt});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 3, bottom: 1),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline_rounded, size: 13, color: pt.danger),
const SizedBox(width: 4),
Text('Nicht gesendet', style: TextStyle(color: pt.danger, fontSize: 12)),
const SizedBox(width: 10),
_link('Wiederholen', pt, () async {
try { await event.sendAgain(); } catch (_) {}
}),
const SizedBox(width: 10),
_link('Löschen', pt, () async {
try { await event.cancelSend(); } catch (_) {}
}),
],
),
);
}
Widget _link(String label, PyramidTheme pt, VoidCallback onTap) => GestureDetector(
onTap: onTap,
child: Text(
label,
style: TextStyle(
color: pt.accent,
fontSize: 12,
decoration: TextDecoration.underline,
decorationColor: pt.accent,
),
),
);
}
// Single message (first in group) with full avatar + name shown
class _MessageBody extends StatelessWidget {
final Event event;
final PyramidTheme pt;
final Timeline? timeline;
final Room? room;
final String currentUserId;
const _MessageBody({
required this.event,
required this.pt,
this.timeline,
this.room,
required this.currentUserId,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_MessageContent(event: event, pt: pt, timeline: timeline),
if (event.status.isError) _SendFailedRow(event: event, pt: pt),
if (timeline != null) _ReactionsRow(event: event, timeline: timeline!, room: room, currentUserId: currentUserId, pt: pt),
],
);
}
}
// Continuation message — no avatar, just indented text with timestamp on hover
class _ContinuationMessage extends StatefulWidget {
final Event event;
final PyramidTheme pt;
final Timeline? timeline;
final Room? room;
final String currentUserId;
const _ContinuationMessage({
required this.event,
required this.pt,
this.timeline,
this.room,
required this.currentUserId,
});
@override
State<_ContinuationMessage> createState() => _ContinuationMessageState();
}
class _ContinuationMessageState extends State<_ContinuationMessage> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Timestamp on hover (in avatar column space)
SizedBox(
width: 38,
child: Padding(
padding: const EdgeInsets.only(top: 4),
child: AnimatedOpacity(
opacity: _hovered ? 1 : 0,
duration: const Duration(milliseconds: 120),
child: Text(
_formatMessageTime(widget.event.originServerTs),
textAlign: TextAlign.center,
style: TextStyle(
color: widget.pt.fgDim,
fontSize: 10,
),
),
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_MessageContent(event: widget.event, pt: widget.pt, timeline: widget.timeline),
if (widget.event.status.isError) _SendFailedRow(event: widget.event, pt: widget.pt),
if (widget.timeline != null)
_ReactionsRow(
event: widget.event,
timeline: widget.timeline!,
room: widget.room,
currentUserId: widget.currentUserId,
pt: widget.pt,
),
],
),
),
],
),
);
}
}
class _MessageContent extends StatelessWidget {
final Event event;
final PyramidTheme pt;
final Timeline? timeline;
const _MessageContent({required this.event, required this.pt, this.timeline});
// Resolves edits: returns the display event (last edit) for this event.
Event get _displayEvent =>
timeline != null ? event.getDisplayEvent(timeline!) : event;
String? _getReplyId() {
final relatesTo = event.content.tryGetMap<String, dynamic>('m.relates_to');
final replyMap = relatesTo?.tryGetMap<String, dynamic>('m.in_reply_to');
return replyMap?['event_id'] as String?;
}
String _stripReplyFallback(String body) {
final norm = body.replaceAll('\r\n', '\n');
if (!norm.startsWith('> ') && !norm.startsWith('>\n')) return norm;
final lines = norm.split('\n');
var i = 0;
while (i < lines.length && (lines[i].startsWith('> ') || lines[i].trimRight() == '>')) {
i++;
}
if (i < lines.length && lines[i].trim().isEmpty) i++;
if (i >= lines.length) return norm;
return lines.skip(i).join('\n').trim();
}
Event? _findReplyEvent(String replyId) {
if (timeline == null) return null;
try {
return timeline!.events.firstWhere((e) => e.eventId == replyId);
} catch (_) {
return null;
}
}
@override
Widget build(BuildContext context) {
final displayEvent = _displayEvent;
final isEdited = displayEvent.eventId != event.eventId;
// Still encrypted (decryption pending or failed)
if (displayEvent.type == EventTypes.Encrypted) {
return Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4,
children: [
Icon(Icons.lock_outline_rounded, size: 13, color: pt.fgDim),
Text(
'Nachricht wird entschlüsselt…',
style: TextStyle(color: pt.fgDim, fontSize: 13, fontStyle: FontStyle.italic),
),
if (timeline != null)
Builder(
builder: (ctx) => GestureDetector(
onTap: () {
timeline!.requestKeys();
ScaffoldMessenger.of(ctx).showSnackBar(
const SnackBar(
content: Text('Schlüssel werden von anderen Geräten angefordert…'),
duration: Duration(seconds: 3),
),
);
},
child: Text(
'Schlüssel anfordern',
style: TextStyle(
color: pt.accent,
fontSize: 12,
decoration: TextDecoration.underline,
decorationColor: pt.accent,
),
),
),
),
],
),
);
}
if (displayEvent.type == EventTypes.Sticker) {
return _MediaContent(event: displayEvent, pt: pt);
}
final msgType = displayEvent.messageType;
if (msgType == 'm.image') {
final ext = displayEvent.body.contains('.')
? displayEvent.body.split('.').last.toLowerCase()
: '';
const kImageExts = {
'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp',
'tiff', 'tif', 'heic', 'heif', 'avif', 'ico', 'jfif',
};
// SVG kann Image.memory nicht dekodieren — über den SVG-Renderer anzeigen.
if (ext == 'svg' ||
displayEvent.content
.tryGetMap<String, Object?>('info')?['mimetype'] ==
'image/svg+xml') {
return _DocPreviewContent(event: displayEvent, pt: pt);
}
if (ext.isNotEmpty && !kImageExts.contains(ext)) {
return _FileContent(event: displayEvent, pt: pt, icon: Icons.insert_drive_file_rounded);
}
return _ImageContent(event: displayEvent, pt: pt);
}
if (msgType == 'm.audio') {
return _AudioContent(event: displayEvent, pt: pt);
}
if (msgType == 'm.video') {
return _VideoContent(event: displayEvent, pt: pt);
}
if (msgType == 'm.file') {
if (DocumentViewer.isInlinePreviewable(displayEvent.body)) {
return _DocPreviewContent(event: displayEvent, pt: pt);
}
if (DocumentViewer.showsInlineCard(displayEvent.body)) {
return _ProprietaryInlineCard(event: displayEvent, pt: pt);
}
return _FileContent(event: displayEvent, pt: pt, icon: Icons.insert_drive_file_rounded);
}
final replyId = _getReplyId();
final replyEvent = replyId != null ? _findReplyEvent(replyId) : null;
final plainBody = replyId != null
? _stripReplyFallback(displayEvent.body)
: displayEvent.body;
// Use formatted_body (HTML) if the event has one, otherwise plain text.
final format = displayEvent.content.tryGet<String>('format');
// Strip the Matrix reply fallback <mx-reply>…</mx-reply> block — we render
// our own quote card (_ReplyPreview) above, so the inline "In reply to @user"
// would be redundant and ugly.
final formattedBody = displayEvent.formattedText.replaceAll(
RegExp(r'<mx-reply>.*?</mx-reply>', caseSensitive: false, dotAll: true),
'',
).trim();
final hasHtml = format == 'org.matrix.custom.html' && formattedBody.isNotEmpty;
final firstUrl = _firstUrl(plainBody);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (replyEvent != null) _ReplyPreview(replyEvent: replyEvent, pt: pt),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: hasHtml
? _MatrixHtmlText(
html: formattedBody,
baseStyle: TextStyle(color: pt.fg, fontSize: 14, height: 1.55),
linkColor: pt.accent,
)
: _PlainLinkText(
text: plainBody,
baseStyle: TextStyle(color: pt.fg, fontSize: 14, height: 1.55),
linkColor: pt.accent,
),
),
if (isEdited)
Text(
'(bearbeitet)',
style: TextStyle(color: pt.fgDim, fontSize: 11, fontStyle: FontStyle.italic),
),
if (firstUrl != null) _LinkPreview(url: firstUrl, pt: pt),
],
);
}
// Extracts the first http(s) URL in the text, for link-preview fetching.
static final RegExp _urlRegex = RegExp(r'https?://[^\s<>()\[\]]+', caseSensitive: false);
String? _firstUrl(String text) {
final m = _urlRegex.firstMatch(text);
if (m == null) return null;
var url = m.group(0)!;
// Trim common trailing punctuation that isn't part of the URL.
url = url.replaceAll(RegExp(r'[.,;:!?]+$'), '');
return url;
}
}
// ─── Link preview card (Open-Graph, WhatsApp/Discord-style) ──────────────────
class _LinkPreviewData {
final String? title;
final String? description;
final String? imageMxc;
const _LinkPreviewData({this.title, this.description, this.imageMxc});
bool get isEmpty =>
(title == null || title!.isEmpty) &&
(description == null || description!.isEmpty) &&
imageMxc == null;
}
class _LinkPreview extends ConsumerStatefulWidget {
final String url;
final PyramidTheme pt;
const _LinkPreview({required this.url, required this.pt});
// url → preview (null = fetched but nothing usable). Avoids refetching on
// every rebuild/scroll.
static final Map<String, _LinkPreviewData?> _cache = {};
@override
ConsumerState<_LinkPreview> createState() => _LinkPreviewState();
}
class _LinkPreviewState extends ConsumerState<_LinkPreview> {
_LinkPreviewData? _data;
bool _done = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
if (_LinkPreview._cache.containsKey(widget.url)) {
setState(() { _data = _LinkPreview._cache[widget.url]; _done = true; });
return;
}
try {
final client = await ref.read(matrixClientProvider.future);
final hs = client.homeserver.toString();
final enc = Uri.encodeQueryComponent(widget.url);
final headers = {'authorization': 'Bearer ${client.accessToken}'};
var res = await client.httpClient.get(
Uri.parse('$hs/_matrix/client/v1/media/preview_url?url=$enc'),
headers: headers,
);
if (res.statusCode == 404) {
// Legacy fallback for older servers.
res = await client.httpClient.get(
Uri.parse('$hs/_matrix/media/v3/preview_url?url=$enc'),
headers: headers,
);
}
_LinkPreviewData? data;
if (res.statusCode == 200) {
final json = jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
data = _LinkPreviewData(
title: (json['og:title'] as String?)?.trim(),
description: (json['og:description'] as String?)?.trim(),
imageMxc: json['og:image'] as String?,
);
if (data.isEmpty) data = null;
}
_LinkPreview._cache[widget.url] = data;
if (mounted) setState(() { _data = data; _done = true; });
} catch (_) {
_LinkPreview._cache[widget.url] = null;
if (mounted) setState(() { _data = null; _done = true; });
}
}
@override
Widget build(BuildContext context) {
final data = _data;
if (!_done || data == null) return const SizedBox.shrink();
final pt = widget.pt;
final client = ref.watch(matrixClientProvider).valueOrNull;
return Padding(
padding: const EdgeInsets.only(top: 6, bottom: 2),
child: GestureDetector(
onTap: () => launchUrl(Uri.parse(widget.url), mode: LaunchMode.externalApplication),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: ClipRRect(
borderRadius: BorderRadius.circular(pt.rBase),
child: Container(
decoration: BoxDecoration(
color: pt.bg2,
border: Border(left: BorderSide(color: pt.accent, width: 3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (data.imageMxc != null && client != null)
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 180),
child: SizedBox(
width: double.infinity,
child: MxcImage(
mxcUri: data.imageMxc!,
client: client,
fit: BoxFit.cover,
placeholder: const SizedBox.shrink(),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 8, 10, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (data.title != null && data.title!.isNotEmpty)
Text(
data.title!,
style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w600),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (data.description != null && data.description!.isNotEmpty) ...[
const SizedBox(height: 3),
Text(
data.description!,
style: TextStyle(color: pt.fgMuted, fontSize: 12, height: 1.35),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
],
),
),
),
),
),
);
}
}
class _ReplyPreview extends StatelessWidget {
final Event replyEvent;
final PyramidTheme pt;
const _ReplyPreview({required this.replyEvent, required this.pt});
@override
Widget build(BuildContext context) {
final senderName = replyEvent.senderFromMemoryOrFallback.calcDisplayname();
// Media-aware preview: show an icon + label instead of a raw filename,
// matching WhatsApp/Discord. Falls back to stripped text for m.text.
final (IconData? icon, String body) = _previewFor(replyEvent);
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: IntrinsicHeight(
child: ClipRRect(
borderRadius: BorderRadius.circular(pt.rSm),
child: Container(
color: pt.accentSoft,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Accent bar flush with the left edge of the tinted card.
Container(width: 3, color: pt.accent),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 5, 10, 5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(senderName, style: TextStyle(color: pt.accent, fontSize: 12, fontWeight: FontWeight.w600)),
Row(
children: [
if (icon != null) ...[
Icon(icon, size: 13, color: pt.fgMuted),
const SizedBox(width: 4),
],
Expanded(
child: Text(
body,
style: TextStyle(color: pt.fgMuted, fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
),
],
),
),
),
),
);
}
(IconData?, String) _previewFor(Event e) {
switch (e.messageType) {
case 'm.image':
return (Icons.image_outlined, 'Bild');
case 'm.video':
return (Icons.videocam_outlined, 'Video');
case 'm.audio':
return (Icons.mic_none_rounded, 'Sprachnachricht');
case 'm.file':
return (Icons.insert_drive_file_outlined, e.body.isNotEmpty ? e.body : 'Datei');
}
if (e.type == EventTypes.Sticker) return (Icons.emoji_emotions_outlined, 'Sticker');
// Plain text: strip nested reply fallback, collapse to first line.
String body = e.body.replaceAll('\r\n', '\n');
if (body.startsWith('> ')) {
final lines = body.split('\n');
var i = 0;
while (i < lines.length && (lines[i].startsWith('> ') || lines[i].trimRight() == '>')) {
i++;
}
if (i < lines.length && lines[i].trim().isEmpty) i++;
if (i < lines.length) body = lines.skip(i).join(' ').trim();
}
return (null, body.split('\n').first);
}
}
class _ImageContent extends ConsumerStatefulWidget {
final Event event;
final PyramidTheme pt;
const _ImageContent({required this.event, required this.pt});
@override
ConsumerState<_ImageContent> createState() => _ImageContentState();
}
class _ImageContentState extends ConsumerState<_ImageContent> {
late Future<_MediaResult> _future;
@override
void initState() {
super.initState();
_future = _load();
}
Future<_MediaResult> _load() async {
final event = widget.event;
final rawUrl = event.content.tryGet<String>('url') ?? '';
if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://')) {
return _MediaResult.network(rawUrl);
}
// Serve from the shared LRU cache if this image was viewed recently.
final cached = MediaCache.instance.get(event.eventId);
if (cached != null) return _MediaResult.bytes(cached);
final client = await ref.read(matrixClientProvider.future);
final mxcUri = event.attachmentMxcUrl;
if (mxcUri == null) return _MediaResult.none();
if (event.isAttachmentEncrypted) {
try {
final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client));
MediaCache.instance.put(event.eventId, file.bytes);
return _MediaResult.bytes(file.bytes);
} catch (e) {
return _MediaResult.error(_classifyMediaError(e));
}
} else {
try {
final httpUri = await mxcUri.getThumbnailUri(client, width: 800, height: 600, method: ThumbnailMethod.scale);
final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'});
if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}');
MediaCache.instance.put(event.eventId, res.bodyBytes);
return _MediaResult.bytes(res.bodyBytes);
} catch (_) {
try {
final httpUri = await mxcUri.getDownloadUri(client);
final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'});
if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}');
MediaCache.instance.put(event.eventId, res.bodyBytes);
return _MediaResult.bytes(res.bodyBytes);
} catch (e) {
return _MediaResult.error(e.toString().split('\n').first);
}
}
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return LayoutBuilder(builder: (context, constraints) {
// Never exceed the available chat width (important on narrow phones),
// capped at the comfortable desktop maximum.
final maxW = constraints.maxWidth.isFinite
? constraints.maxWidth.clamp(80.0, 420.0)
: 420.0;
const maxH = 320.0;
// Reserve the final display box up-front from the event's info dimensions,
// so the layout doesn't shift (and yank the scroll position) when the image
// finishes loading. Null when dimensions are absent.
final box = _reservedImageSize(widget.event, maxW: maxW, maxH: maxH);
return FutureBuilder<_MediaResult>(
future: _future,
builder: (context, snap) {
Widget inner;
if (snap.connectionState == ConnectionState.waiting) {
inner = Center(child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: pt.fgDim)));
} else if (snap.hasData) {
final result = snap.data!;
if (result.bytes != null) {
inner = Image.memory(
result.bytes!,
fit: BoxFit.contain,
gaplessPlayback: true,
errorBuilder: (context, e, stack) => _mediaFallback(pt, e.toString().split('\n').first, widget.event.body),
);
} else if (result.networkUrl != null) {
inner = Image.network(result.networkUrl!, fit: BoxFit.contain);
} else {
inner = _mediaFallback(pt, result.error, widget.event.body);
}
} else {
inner = _mediaFallback(pt, null, widget.event.body);
}
final result = snap.data;
return GestureDetector(
onTap: (snap.hasData && result != null)
? () => MediaViewer.show(
context,
senderName: widget.event.senderFromMemoryOrFallback.calcDisplayname(),
filename: widget.event.body,
bytes: result.bytes,
networkUrl: result.networkUrl,
event: widget.event,
)
: null,
child: Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(top: 4, bottom: 4),
width: box?.width,
height: box?.height,
constraints: box == null
? BoxConstraints(
minWidth: 120, minHeight: 90,
maxWidth: maxW, maxHeight: maxH,
)
: null,
decoration: BoxDecoration(
color: pt.bg1,
borderRadius: BorderRadius.circular(pt.rBase),
),
clipBehavior: Clip.hardEdge,
child: inner,
),
),
);
},
);
});
}
}
// Computes the on-screen box for an image/video event, fitting within
// [maxW]×[maxH] while preserving the aspect ratio from the event's `info`
// (w/h). Used to reserve layout space before the media loads so nothing shifts.
// Returns null when the event carries no dimensions — callers should then use
// loose constraints instead of a fixed box.
Size? _reservedImageSize(Event event, {required double maxW, required double maxH}) {
final info = event.content.tryGetMap<String, Object?>('info');
final w = (info?['w'] as num?)?.toDouble();
final h = (info?['h'] as num?)?.toDouble();
if (w == null || h == null || w <= 0 || h <= 0) return null;
// Fit inside maxW×maxH without distorting; never upscale small images.
var scale = (maxW / w).clamp(0.0, maxH / h);
if (scale > 1.0) scale = 1.0;
return Size(w * scale, h * scale);
}
// Downloads media bytes then hands off to PyramidAudioPlayer / PyramidVideoPlayer
class _AudioContent extends ConsumerStatefulWidget {
final Event event;
final PyramidTheme pt;
const _AudioContent({required this.event, required this.pt});
@override
ConsumerState<_AudioContent> createState() => _AudioContentState();
}
class _AudioContentState extends ConsumerState<_AudioContent> {
late Future<_MediaResult> _future;
@override
void initState() {
super.initState();
_future = _loadBytes();
}
Future<_MediaResult> _loadBytes() async {
final client = await ref.read(matrixClientProvider.future);
final event = widget.event;
if (event.isAttachmentEncrypted) {
try {
final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client));
return _MediaResult.bytes(file.bytes);
} catch (e) {
return _MediaResult.error(_classifyMediaError(e));
}
}
final mxcUri = event.attachmentMxcUrl;
if (mxcUri == null) return _MediaResult.none();
try {
final httpUri = await mxcUri.getDownloadUri(client);
final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'});
if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}');
return _MediaResult.bytes(res.bodyBytes);
} catch (e) {
return _MediaResult.error(e.toString().split('\n').first);
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return FutureBuilder<_MediaResult>(
future: _future,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(mainAxisSize: MainAxisSize.min, children: [
SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: pt.fgDim)),
const SizedBox(width: 10),
Text('Lade Audio…', style: TextStyle(color: pt.fgMuted, fontSize: 12)),
]),
);
}
final result = snap.data;
if (result?.bytes == null) {
return _mediaFallback(pt, result?.error, widget.event.body);
}
return PyramidAudioPlayer(
bytes: result!.bytes!,
filename: widget.event.body,
pt: pt,
isVoice: widget.event.content['msgtype'] == 'm.audio',
);
},
);
}
}
class _VideoContent extends ConsumerStatefulWidget {
final Event event;
final PyramidTheme pt;
const _VideoContent({required this.event, required this.pt});
@override
ConsumerState<_VideoContent> createState() => _VideoContentState();
}
class _VideoContentState extends ConsumerState<_VideoContent> {
late Future<_MediaResult> _future;
@override
void initState() {
super.initState();
_future = _loadBytes();
}
Future<_MediaResult> _loadBytes() async {
final client = await ref.read(matrixClientProvider.future);
final event = widget.event;
if (event.isAttachmentEncrypted) {
try {
final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client));
return _MediaResult.bytes(file.bytes);
} catch (e) {
return _MediaResult.error(_classifyMediaError(e));
}
}
final mxcUri = event.attachmentMxcUrl;
if (mxcUri == null) return _MediaResult.none();
try {
final httpUri = await mxcUri.getDownloadUri(client);
final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'});
if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}');
return _MediaResult.bytes(res.bodyBytes);
} catch (e) {
return _MediaResult.error(e.toString().split('\n').first);
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return FutureBuilder<_MediaResult>(
future: _future,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return Container(
margin: const EdgeInsets.only(top: 4, bottom: 4),
constraints: const BoxConstraints(maxWidth: 360),
height: 200,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(color: pt.border),
),
child: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)),
);
}
final result = snap.data;
if (result?.bytes == null) {
return _FileContent(event: widget.event, pt: pt, icon: Icons.videocam_rounded);
}
return PyramidVideoPlayer(
bytes: result!.bytes!,
filename: widget.event.body,
pt: pt,
senderName: widget.event.senderFromMemoryOrFallback.calcDisplayname(),
);
},
);
}
}
class _MediaContent extends ConsumerStatefulWidget {
final Event event;
final PyramidTheme pt;
const _MediaContent({required this.event, required this.pt});
@override
ConsumerState<_MediaContent> createState() => _MediaContentState();
}
class _MediaContentState extends ConsumerState<_MediaContent> {
late Future<_MediaResult> _future;
@override
void initState() {
super.initState();
_future = _load();
}
Future<_MediaResult> _load() async {
final client = await ref.read(matrixClientProvider.future);
final event = widget.event;
// External URL (e.g. Giphy GIFs) — not an MXC URI
final rawUrl = event.content.tryGet<String>('url') ?? '';
if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://')) {
return _MediaResult.network(rawUrl);
}
final mxcUri = event.attachmentMxcUrl;
if (mxcUri == null) return _MediaResult.none();
if (event.isAttachmentEncrypted) {
try {
final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client));
return _MediaResult.bytes(file.bytes);
} catch (e) {
return _MediaResult.error(_classifyMediaError(e));
}
} else {
try {
final httpUri = await mxcUri.getThumbnailUri(
client,
width: 800,
height: 600,
method: ThumbnailMethod.scale,
);
final res = await client.httpClient.get(
httpUri,
headers: {'authorization': 'Bearer ${client.accessToken}'},
);
if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}');
return _MediaResult.bytes(res.bodyBytes);
} catch (_) {
// Fall back to full-size download
try {
final httpUri = await mxcUri.getDownloadUri(client);
final res = await client.httpClient.get(
httpUri,
headers: {'authorization': 'Bearer ${client.accessToken}'},
);
if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}');
return _MediaResult.bytes(res.bodyBytes);
} catch (e) {
return _MediaResult.error(e.toString().split('\n').first);
}
}
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final isSticker = widget.event.type == EventTypes.Sticker;
final maxW = isSticker ? 160.0 : 420.0;
final maxH = isSticker ? 160.0 : 320.0;
return FutureBuilder<_MediaResult>(
future: _future,
builder: (context, snap) {
Widget inner;
if (snap.connectionState == ConnectionState.waiting) {
inner = Center(
child: SizedBox(
width: 24, height: 24,
child: CircularProgressIndicator(strokeWidth: 2, color: pt.fgDim),
),
);
} else if (snap.hasData) {
final result = snap.data!;
if (result.networkUrl != null) {
inner = Image.network(
result.networkUrl!,
fit: BoxFit.contain,
errorBuilder: (context, e, stack) => _fallback(pt),
);
} else if (result.bytes != null) {
inner = Image.memory(
result.bytes!,
fit: BoxFit.contain,
errorBuilder: (context, e, stack) => _fallback(pt),
);
} else {
inner = _fallback(pt, result.error);
}
} else {
inner = _fallback(pt);
}
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(top: 4, bottom: 4),
constraints: BoxConstraints(maxWidth: maxW, maxHeight: maxH),
decoration: isSticker ? null : BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(color: pt.border),
),
clipBehavior: isSticker ? Clip.none : Clip.hardEdge,
child: inner,
),
);
},
);
}
Widget _fallback(PyramidTheme pt, [String? error]) =>
_mediaFallback(pt, error, widget.event.body);
}
// Downloads bytes for a PDF/SVG file and shows PdfInlinePreview or SvgImage.
class _DocPreviewContent extends ConsumerStatefulWidget {
final Event event;
final PyramidTheme pt;
const _DocPreviewContent({required this.event, required this.pt});
@override
ConsumerState<_DocPreviewContent> createState() => _DocPreviewContentState();
}
class _DocPreviewContentState extends ConsumerState<_DocPreviewContent> {
late Future<_MediaResult> _future;
@override
void initState() {
super.initState();
_future = _loadBytes();
}
Future<_MediaResult> _loadBytes() async {
final event = widget.event;
// Serve from cache if we already downloaded this attachment this session.
final cached = _DocBytesCache.get(event.eventId);
if (cached != null) return _MediaResult.bytes(cached);
final client = await ref.read(matrixClientProvider.future);
if (event.isAttachmentEncrypted) {
try {
final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client));
_DocBytesCache.put(event.eventId, file.bytes);
return _MediaResult.bytes(file.bytes);
} catch (e) {
return _MediaResult.error(_classifyMediaError(e));
}
}
final mxcUri = event.attachmentMxcUrl;
if (mxcUri == null) return _MediaResult.none();
try {
final httpUri = await mxcUri.getDownloadUri(client);
final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'});
if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}');
_DocBytesCache.put(event.eventId, res.bodyBytes);
return _MediaResult.bytes(res.bodyBytes);
} catch (e) {
return _MediaResult.error(e.toString().split('\n').first);
}
}
Future<void> _saveFile(Uint8List bytes) async {
try {
Directory? dir;
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
dir = await getDownloadsDirectory();
}
dir ??= await getTemporaryDirectory();
final filename = widget.event.body.isNotEmpty ? widget.event.body : 'file';
final ext = filename.contains('.') ? filename.split('.').last : 'bin';
final name = 'pyramid_${DateTime.now().millisecondsSinceEpoch}.$ext';
final file = File('${dir.path}/$name');
await file.writeAsBytes(bytes);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Gespeichert: ${file.path}'),
duration: const Duration(seconds: 4),
));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler: $e')));
}
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final ext = widget.event.body.contains('.')
? widget.event.body.split('.').last.toLowerCase()
: '';
return FutureBuilder<_MediaResult>(
future: _future,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return Container(
width: 280,
height: 80,
margin: const EdgeInsets.only(top: 4, bottom: 4),
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(color: pt.border),
),
child: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)),
);
}
final result = snap.data;
if (result?.bytes == null) {
return _FileContent(event: widget.event, pt: pt, icon: Icons.picture_as_pdf_outlined);
}
final bytes = result!.bytes!;
final sender = widget.event.senderFromMemoryOrFallback.calcDisplayname();
if (ext == 'pdf' || ext == 'ai' || ext == 'eps') {
return PdfInlinePreview(
bytes: bytes,
filename: widget.event.body,
pt: pt,
senderName: sender,
onDownload: () => _saveFile(bytes),
);
}
// SVG — show inline like an image
return GestureDetector(
onTap: () => DocumentViewer.show(
context,
filename: widget.event.body,
bytes: bytes,
senderName: sender,
),
child: Container(
margin: const EdgeInsets.only(top: 4, bottom: 4),
constraints: const BoxConstraints(maxWidth: 300, maxHeight: 300),
decoration: BoxDecoration(
color: pt.bg1,
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(color: pt.border),
),
clipBehavior: Clip.hardEdge,
child: SvgPicture.memory(bytes, fit: BoxFit.contain),
),
);
},
);
}
}
// Inline card for proprietary formats — shows immediately from event metadata,
// downloads lazily only when the user taps or hits the download button.
class _ProprietaryInlineCard extends ConsumerStatefulWidget {
final Event event;
final PyramidTheme pt;
const _ProprietaryInlineCard({required this.event, required this.pt});
@override
ConsumerState<_ProprietaryInlineCard> createState() => _ProprietaryInlineCardState();
}
class _ProprietaryInlineCardState extends ConsumerState<_ProprietaryInlineCard> {
bool _loading = false;
Uint8List? _cachedBytes;
String get _filename => widget.event.body.isNotEmpty ? widget.event.body : 'file';
String get _ext => _filename.contains('.') ? _filename.split('.').last.toLowerCase() : '';
int? get _fileSize => widget.event.content.tryGetMap<String, dynamic>('info')?['size'] as int?;
String _fmtSize(int b) {
if (b < 1024) return '$b B';
if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(1)} KB';
return '${(b / (1024 * 1024)).toStringAsFixed(1)} MB';
}
IconData get _icon => switch (_ext) {
'psd' || 'psb' => Icons.photo_camera_outlined,
'ai' || 'eps' => Icons.gesture_outlined,
'indd' || 'inx' => Icons.menu_book_outlined,
'afphoto' || 'afdesign' || 'afpub' => Icons.palette_outlined,
'sketch' || 'fig' || 'xd' => Icons.design_services_outlined,
'doc' => Icons.description_outlined,
'ppt' => Icons.slideshow_outlined,
'xls' => Icons.table_chart_outlined,
_ => Icons.insert_drive_file_outlined,
};
Future<Uint8List> _download() async {
if (_cachedBytes != null) return _cachedBytes!;
final event = widget.event;
final shared = _DocBytesCache.get(event.eventId);
if (shared != null) return _cachedBytes = shared;
final client = await ref.read(matrixClientProvider.future);
if (event.isAttachmentEncrypted) {
final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client));
_DocBytesCache.put(event.eventId, file.bytes);
return _cachedBytes = file.bytes;
}
final mxcUri = event.attachmentMxcUrl;
if (mxcUri == null) throw Exception('Kein URI');
final httpUri = await mxcUri.getDownloadUri(client);
final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'});
if (res.statusCode != 200) throw Exception('HTTP ${res.statusCode}');
_DocBytesCache.put(event.eventId, res.bodyBytes);
return _cachedBytes = res.bodyBytes;
}
Future<void> _openViewer() async {
if (_loading) return;
setState(() => _loading = true);
try {
final bytes = await _download();
if (!mounted) return;
await DocumentViewer.show(
context,
filename: _filename,
bytes: bytes,
senderName: widget.event.senderFromMemoryOrFallback.calcDisplayname(),
);
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler: $e'), duration: const Duration(seconds: 3)));
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _save() async {
if (_loading) return;
setState(() => _loading = true);
try {
final bytes = await _download();
Directory? dir;
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
dir = await getDownloadsDirectory();
}
dir ??= await getTemporaryDirectory();
final outFile = File('${dir.path}/pyramid_${DateTime.now().millisecondsSinceEpoch}.$_ext');
await outFile.writeAsBytes(bytes);
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Gespeichert: ${outFile.path}'), duration: const Duration(seconds: 4)));
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler: $e'), duration: const Duration(seconds: 3)));
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final extLabel = _ext.toUpperCase();
final size = _fileSize;
return GestureDetector(
onTap: _openViewer,
child: Container(
width: 280,
margin: const EdgeInsets.only(top: 4, bottom: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(color: pt.border),
),
clipBehavior: Clip.hardEdge,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Visual area
Container(
width: 280,
height: 140,
color: pt.accentSoft,
child: Stack(
alignment: Alignment.center,
children: [
Icon(_icon, size: 56, color: pt.accent.withAlpha(180)),
Positioned(
bottom: 12,
right: 12,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: pt.accent,
borderRadius: BorderRadius.circular(4),
),
child: Text(
extLabel.length > 4 ? extLabel.substring(0, 4) : extLabel,
style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700),
),
),
),
],
),
),
// Footer
Container(
color: pt.bg2,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
child: Row(
children: [
Icon(_icon, size: 14, color: pt.fgDim),
const SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(_filename, style: TextStyle(color: pt.fg, fontSize: 12, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis),
if (size != null)
Text(_fmtSize(size), style: TextStyle(color: pt.fgMuted, fontSize: 10)),
],
),
),
if (_loading)
SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent))
else
GestureDetector(
onTap: _save,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(Icons.download_rounded, size: 16, color: pt.fgMuted),
),
),
],
),
),
],
),
),
);
}
}
class _FileContent extends ConsumerStatefulWidget {
final Event event;
final PyramidTheme pt;
final IconData icon;
const _FileContent({required this.event, required this.pt, required this.icon});
@override
ConsumerState<_FileContent> createState() => _FileContentState();
}
class _FileContentState extends ConsumerState<_FileContent> {
bool _saving = false;
bool _previewing = false;
String get _filename => widget.event.body.isNotEmpty ? widget.event.body : 'file';
String _ext() {
final n = _filename;
return n.contains('.') ? n.split('.').last.toLowerCase() : 'bin';
}
Future<void> _preview() async {
if (_previewing || _saving) return;
setState(() => _previewing = true);
try {
final client = await ref.read(matrixClientProvider.future);
final event = widget.event;
Uint8List bytes;
if (event.isAttachmentEncrypted) {
final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client));
bytes = file.bytes;
} else {
final mxcUri = event.attachmentMxcUrl;
if (mxcUri == null) throw Exception('Kein URI');
final httpUri = await mxcUri.getDownloadUri(client);
final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'});
if (res.statusCode != 200) throw Exception('HTTP ${res.statusCode}');
bytes = res.bodyBytes;
}
if (!mounted) return;
await DocumentViewer.show(
context,
filename: _filename,
bytes: bytes,
senderName: event.senderFromMemoryOrFallback.calcDisplayname(),
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fehler: $e'), duration: const Duration(seconds: 3)),
);
}
} finally {
if (mounted) setState(() => _previewing = false);
}
}
Future<void> _save({bool openAfter = false}) async {
if (_saving) return;
setState(() => _saving = true);
try {
final client = await ref.read(matrixClientProvider.future);
final event = widget.event;
Uint8List bytes;
if (event.isAttachmentEncrypted) {
final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client));
bytes = file.bytes;
} else {
final mxcUri = event.attachmentMxcUrl;
if (mxcUri == null) throw Exception('Kein URI');
final httpUri = await mxcUri.getDownloadUri(client);
final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'});
if (res.statusCode != 200) throw Exception('HTTP ${res.statusCode}');
bytes = res.bodyBytes;
}
Directory? dir;
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
dir = await getDownloadsDirectory();
}
dir ??= await getTemporaryDirectory();
final name = 'pyramid_${DateTime.now().millisecondsSinceEpoch}.${_ext()}';
final outFile = File('${dir.path}/$name');
await outFile.writeAsBytes(bytes);
if (openAfter) {
if (Platform.isWindows) {
await Process.run('cmd', ['/c', 'start', '', outFile.path]);
} else if (Platform.isMacOS) {
await Process.run('open', [outFile.path]);
} else {
await Process.run('xdg-open', [outFile.path]);
}
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(openAfter ? 'Geöffnet: ${outFile.path}' : 'Gespeichert: ${outFile.path}'),
duration: const Duration(seconds: 4),
));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fehler: $e'), duration: const Duration(seconds: 3)),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final isVideo = widget.event.messageType == 'm.video';
return Container(
margin: const EdgeInsets.only(top: 4, bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
constraints: const BoxConstraints(maxWidth: 340),
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(pt.rBase),
border: Border.all(color: pt.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(color: pt.accentSoft, borderRadius: BorderRadius.circular(pt.rSm)),
child: Icon(widget.icon, size: 22, color: pt.accent),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(_filename, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500), maxLines: 2, overflow: TextOverflow.ellipsis),
const SizedBox(height: 2),
Text(_ext().toUpperCase(), style: TextStyle(color: pt.fgDim, fontSize: 11)),
],
),
),
const SizedBox(width: 8),
if (_saving || _previewing)
SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent))
else
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (DocumentViewer.supportsPreview(_filename))
IconButton(
onPressed: _preview,
tooltip: 'Vorschau',
icon: Icon(Icons.visibility_outlined, size: 22, color: pt.fgMuted),
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
padding: EdgeInsets.zero,
),
if (isVideo)
IconButton(
onPressed: () => _save(openAfter: true),
tooltip: 'Öffnen',
icon: Icon(Icons.play_circle_outline_rounded, size: 22, color: pt.fgMuted),
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
padding: EdgeInsets.zero,
),
IconButton(
onPressed: () => _save(),
tooltip: 'Herunterladen',
icon: Icon(Icons.download_rounded, size: 22, color: pt.fgMuted),
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
padding: EdgeInsets.zero,
),
],
),
],
),
);
}
}
String _classifyMediaError(Object e) {
final s = e.toString();
if (s.contains('HandshakeException') || s.contains('CERTIFICATE') || s.contains('SocketException')) {
return 'Verbindungsfehler (TLS/Netzwerk)';
}
if (s.contains('HTTP 4') || s.contains('HTTP 5') || s.contains('Unknown error when fetching')) {
return 'Datei nicht verfügbar (Serverfehler)';
}
if (s.contains('decrypt') || s.contains('Decrypt') || s.contains('OlmException')) {
return 'Entschlüsselung fehlgeschlagen';
}
return 'Download fehlgeschlagen';
}
// Thin wrapper over the shared MediaCache so document previews share the same
// LRU byte budget as chat images (keeps the last-viewed media instantly
// available, evicts the oldest).
class _DocBytesCache {
static Uint8List? get(String eventId) => MediaCache.instance.get(eventId);
static void put(String eventId, Uint8List bytes) =>
MediaCache.instance.put(eventId, bytes);
}
// Download callback that validates HTTP status before returning bytes.
// Prevents false "decryption failed" errors when the server returns an
// error page instead of the actual encrypted file bytes.
Future<Uint8List> Function(Uri) _checkedDownload(Client client) {
return (Uri url) async {
final res = await client.httpClient.get(
url,
headers: {'authorization': 'Bearer ${client.accessToken}'},
);
if (res.statusCode != 200) {
throw Exception('HTTP ${res.statusCode}: ${res.body}');
}
return res.bodyBytes;
};
}
Widget _mediaFallback(PyramidTheme pt, String? error, String body) => Padding(
padding: const EdgeInsets.all(12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.broken_image_outlined, size: 16, color: pt.fgDim),
const SizedBox(width: 6),
Flexible(child: Text(error ?? body, style: TextStyle(color: pt.fgDim, fontSize: 12), overflow: TextOverflow.ellipsis)),
],
),
);
class _MediaResult {
final String? networkUrl;
final Uint8List? bytes;
final String? error;
_MediaResult._({this.networkUrl, this.bytes, this.error});
factory _MediaResult.network(String url) => _MediaResult._(networkUrl: url);
factory _MediaResult.bytes(Uint8List b) => _MediaResult._(bytes: b);
factory _MediaResult.error(String e) => _MediaResult._(error: e);
factory _MediaResult.none() => _MediaResult._();
}
// ── Reactions row ─────────────────────────────────────────────────────────
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 = {};
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);
}
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(
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;
const _ReactionChip({
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 MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
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 _Avatar extends ConsumerStatefulWidget {
final String initial;
final Color color;
final PyramidTheme pt;
final double size;
final Uri? avatarUri;
const _Avatar({
required this.initial,
required this.color,
required this.pt,
required this.size,
this.avatarUri,
});
@override
ConsumerState<_Avatar> createState() => _AvatarState();
}
class _AvatarState extends ConsumerState<_Avatar> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final client = ref.watch(matrixClientProvider).valueOrNull;
final radius = BorderRadius.circular(widget.size / 2);
Widget child;
if (client != null && widget.avatarUri != null) {
child = MxcAvatar(
mxcUri: widget.avatarUri,
client: client,
size: widget.size,
borderRadius: radius,
placeholder: (_) => _InitialBox(
initial: widget.initial,
color: widget.color,
size: widget.size,
radius: radius,
),
);
} else {
child = _InitialBox(
initial: widget.initial,
color: widget.color,
size: widget.size,
radius: radius,
);
}
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutBack,
transform: _hovered
? Matrix4.diagonal3Values(1.06, 1.06, 1.0)
: Matrix4.identity(),
transformAlignment: Alignment.center,
width: widget.size,
height: widget.size,
child: child,
),
);
}
}
class _InitialBox extends StatelessWidget {
final String initial;
final Color color;
final double size;
final BorderRadius radius;
const _InitialBox({required this.initial, required this.color, required this.size, required this.radius});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(color: color, borderRadius: radius),
child: Center(
child: Text(initial, style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)),
),
);
}
}
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 _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)),
),
),
),
);
}
}
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),
),
),
),
),
);
}
}
// Inline "Neue Nachrichten" divider rendered inside a MessageGroup when the
// fullyRead event is in the middle of the group (same sender, <5 min window).
class _InlineUnreadDivider extends StatelessWidget {
final PyramidTheme pt;
const _InlineUnreadDivider({required this.pt});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 4),
child: Row(
children: [
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text(
'Neue Nachrichten',
style: TextStyle(color: pt.accent, fontSize: 11, fontWeight: FontWeight.w600),
),
),
Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
],
),
);
}
}
// Date divider between message groups
class DateDivider extends StatelessWidget {
final DateTime date;
const DateDivider({super.key, required this.date});
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row(
children: [
Expanded(child: Divider(color: pt.border, height: 1)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
_formatDate(date),
style: TextStyle(
color: pt.fgDim,
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.06 * 11,
),
),
),
Expanded(child: Divider(color: pt.border, height: 1)),
],
),
);
}
String _formatDate(DateTime d) {
final now = DateTime.now();
if (d.year == now.year && d.month == now.month && d.day == now.day) {
return 'Heute · ${d.day}. ${_monthName(d.month)}';
}
final yesterday = now.subtract(const Duration(days: 1));
if (d.year == yesterday.year && d.month == yesterday.month && d.day == yesterday.day) {
return 'Gestern';
}
return '${d.day}. ${_monthName(d.month)} ${d.year}';
}
String _monthName(int m) => const [
'', 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
][m];
}
class _MessageStatus extends StatelessWidget {
final Event lastEvent;
final PyramidTheme pt;
const _MessageStatus({required this.lastEvent, required this.pt});
@override
Widget build(BuildContext context) {
final status = lastEvent.status;
if (status == EventStatus.error) {
return Icon(Icons.error_outline_rounded, size: 12, color: pt.danger);
}
if (status == EventStatus.sending) {
return Icon(Icons.access_time_rounded, size: 12, color: pt.fgDim);
}
// Sent (delivered). "Read" is shown by the per-event ✓✓ marker.
return Icon(Icons.done_rounded, size: 13, color: pt.fgDim);
}
}
// Read receipt: a ✓✓ shown at the partner's last-read message (any sender),
// so it moves next to their own message when they reply.
class _ReadMarker extends StatelessWidget {
final PyramidTheme pt;
const _ReadMarker({required this.pt});
// Goldener Doppelhaken — bewusst nicht die Akzentfarbe, damit "gelesen"
// unabhängig vom Theme sofort erkennbar ist.
static const _gold = Color(0xFFE6B422);
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.only(right: 24, top: 1, bottom: 1),
child: Align(
alignment: Alignment.centerRight,
child: Icon(Icons.done_all_rounded, size: 13, color: _gold),
),
);
}
}
// ─── Matrix HTML renderer ──────────────────────────────────────────────────────
//
// Handles the subset of HTML that Matrix clients emit in formatted_body:
// <b>/<strong> → bold
// <i>/<em> → italic
// <del>/<s> → strikethrough
// <u> → underline
// <code> → monospace, slightly different background
// <a href> → tappable link (url_launcher)
// <br> → newline
// HTML entities → decoded
// Anything else → text content preserved, tags stripped
//
// Note: <pre> and <blockquote> are simplified (rendered as indented code/text).
class _MatrixHtmlText extends StatelessWidget {
final String html;
final TextStyle baseStyle;
final Color linkColor;
const _MatrixHtmlText({
required this.html,
required this.baseStyle,
required this.linkColor,
});
@override
Widget build(BuildContext context) {
final spans = _parseHtml(html, baseStyle, linkColor);
return SelectionArea(
child: RichText(text: TextSpan(children: spans, style: baseStyle)),
);
}
List<InlineSpan> _parseHtml(String html, TextStyle base, Color linkColor) {
// Normalise line endings and collapse whitespace around block elements.
var src = html
.replaceAll('\r\n', '\n')
.replaceAll(RegExp(r'<br\s*/?>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'</(p|div|blockquote|pre|li)>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'<(p|div|li)[^>]*>', caseSensitive: false), '')
.replaceAll(RegExp(r'<blockquote[^>]*>', caseSensitive: false), ' ')
.replaceAll(RegExp(r'<pre[^>]*>', caseSensitive: false), '');
final spans = <InlineSpan>[];
// Simple recursive stack-based parser.
// We tokenise into (tag | text) chunks, then apply style based on open tags.
final tagRe = RegExp(r'<(/?)(\w+)([^>]*)>', caseSensitive: false);
var bold = 0;
var italic = 0;
var strike = 0;
var underline = 0;
var code = 0;
String? pendingHref;
int pos = 0;
for (final m in tagRe.allMatches(src)) {
// Text before this tag.
if (m.start > pos) {
final text = _decodeEntities(src.substring(pos, m.start));
if (text.isNotEmpty) {
spans.add(_buildSpan(
text, base, bold > 0, italic > 0, strike > 0, underline > 0,
code > 0, pendingHref, linkColor,
));
if (pendingHref != null) pendingHref = null;
}
}
pos = m.end;
final closing = m.group(1) == '/';
final tag = m.group(2)!.toLowerCase();
final attrs = m.group(3) ?? '';
if (closing) {
switch (tag) {
case 'b': case 'strong': bold = (bold - 1).clamp(0, 99); break;
case 'i': case 'em': italic = (italic - 1).clamp(0, 99); break;
case 'del': case 's': case 'strike': strike = (strike - 1).clamp(0, 99); break;
case 'u': underline = (underline - 1).clamp(0, 99); break;
case 'code': code = (code - 1).clamp(0, 99); break;
case 'a': pendingHref = null; break;
}
} else {
switch (tag) {
case 'b': case 'strong': bold++; break;
case 'i': case 'em': italic++; break;
case 'del': case 's': case 'strike': strike++; break;
case 'u': underline++; break;
case 'code': code++; break;
case 'a':
final hrefMatch = RegExp("""href=["']([^"']+)["']""", caseSensitive: false)
.firstMatch(attrs);
pendingHref = hrefMatch?.group(1);
break;
}
}
}
// Remaining text after last tag.
if (pos < src.length) {
final text = _decodeEntities(src.substring(pos));
if (text.isNotEmpty) {
spans.add(_buildSpan(
text, base, bold > 0, italic > 0, strike > 0, underline > 0,
code > 0, pendingHref, linkColor,
));
}
}
return spans;
}
InlineSpan _buildSpan(
String text,
TextStyle base,
bool bold, bool italic, bool strike, bool underline, bool code,
String? href, Color linkColor,
) {
var style = base.copyWith(
fontWeight: bold ? FontWeight.bold : null,
fontStyle: italic ? FontStyle.italic : null,
decoration: TextDecoration.combine([
if (strike) TextDecoration.lineThrough,
if (underline) TextDecoration.underline,
]),
fontFamily: code ? 'monospace' : null,
fontSize: code ? (base.fontSize ?? 14) * 0.9 : null,
);
if (href != null) {
style = style.copyWith(
color: linkColor,
decoration: TextDecoration.underline,
decorationColor: linkColor,
);
return TextSpan(
text: text,
style: style,
recognizer: TapGestureRecognizer()
..onTap = () => launchUrl(Uri.parse(href), mode: LaunchMode.externalApplication),
);
}
return TextSpan(text: text, style: style);
}
static String _decodeEntities(String s) => s
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&apos;', "'")
.replaceAll('&nbsp;', ' ');
}
// ─── Plain text with clickable URLs ───────────────────────────────────────────
class _PlainLinkText extends StatelessWidget {
final String text;
final TextStyle baseStyle;
final Color linkColor;
const _PlainLinkText({
required this.text,
required this.baseStyle,
required this.linkColor,
});
static final _urlRe = RegExp(
r'https?://[^\s\]\)>]+',
caseSensitive: false,
);
@override
Widget build(BuildContext context) {
final matches = _urlRe.allMatches(text).toList();
if (matches.isEmpty) {
return SelectableText(text, style: baseStyle);
}
final spans = <InlineSpan>[];
int pos = 0;
for (final m in matches) {
if (m.start > pos) {
spans.add(TextSpan(text: text.substring(pos, m.start)));
}
final url = m.group(0)!;
spans.add(TextSpan(
text: url,
style: baseStyle.copyWith(
color: linkColor,
decoration: TextDecoration.underline,
decorationColor: linkColor,
),
recognizer: TapGestureRecognizer()
..onTap = () => launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication),
));
pos = m.end;
}
if (pos < text.length) {
spans.add(TextSpan(text: text.substring(pos)));
}
return SelectionArea(
child: RichText(text: TextSpan(children: spans, style: baseStyle)),
);
}
}