5bf45a9366
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>
1001 lines
36 KiB
Dart
1001 lines
36 KiB
Dart
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._();
|
||
}
|