6400fb639c
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.
2776 lines
94 KiB
Dart
2776 lines
94 KiB
Dart
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('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll(''', "'")
|
||
.replaceAll(''', "'")
|
||
.replaceAll(' ', ' ');
|
||
}
|
||
|
||
// ─── 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)),
|
||
);
|
||
}
|
||
}
|