Files
pyramid/lib/features/chat/message/message_media.dart
T
Bernd Steckmeister 5bf45a9366 refactor: Gott-Datei message_group.dart in 7 Dateien aufgeteilt
Gleiche Technik wie bei settings_modal.dart: reine Verschiebung per Dart
part/part-of, keine Logikänderung. message_group.dart schrumpft von 2775
auf 30 Zeilen (nur noch Bibliothekskopf). Verifiziert per automatisiertem
Blockvergleich gegen das Original und flutter analyze (Baseline unverändert).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 10:58:46 +02:00

1001 lines
36 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
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.
part of '../message_group.dart';
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);
}
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);
}
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),
),
);
},
);
}
}
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';
}
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._();
}