import 'dart:io'; import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; import 'package:mime/mime.dart'; import 'package:pyramid/core/theme.dart'; class AttachmentDialog extends StatefulWidget { final List files; final Room room; final Event? replyTo; const AttachmentDialog({ super.key, required this.files, required this.room, this.replyTo, }); static Future show( BuildContext context, List files, Room room, { Event? replyTo, }) async { // Snappy Scale+Fade-Eingangsanimation statt Standard-Einblendung. return await showGeneralDialog( context: context, barrierDismissible: true, barrierLabel: 'Anhang', barrierColor: Colors.black54, transitionDuration: const Duration(milliseconds: 180), pageBuilder: (_, _, _) => AttachmentDialog(files: files, room: room, replyTo: replyTo), transitionBuilder: (_, anim, _, child) { final curved = CurvedAnimation(parent: anim, curve: Curves.easeOutBack); return FadeTransition( opacity: anim, child: ScaleTransition(scale: Tween(begin: 0.92, end: 1.0).animate(curved), child: child), ); }, ) ?? false; } @override State createState() => _AttachmentDialogState(); } class _AttachmentDialogState extends State { final _msgCtrl = TextEditingController(); bool _sending = false; String? _error; bool _compress = false; bool get _hasImages => widget.files.any((f) { final mime = lookupMimeType(f.path.split(Platform.pathSeparator).last) ?? ''; return mime.startsWith('image/'); }); @override void dispose() { _msgCtrl.dispose(); super.dispose(); } Future _send() async { setState(() { _sending = true; _error = null; }); try { for (final file in widget.files) { final bytes = await file.readAsBytes(); final filename = file.path.split(Platform.pathSeparator).last; final mimeType = lookupMimeType(filename) ?? 'application/octet-stream'; final matrixFile = MatrixFile(bytes: bytes, name: filename, mimeType: mimeType); final isImage = mimeType.startsWith('image/'); await widget.room.sendFileEvent( matrixFile, inReplyTo: widget.replyTo, shrinkImageMaxDimension: (_compress && isImage) ? 1600 : null, ); } if (_msgCtrl.text.trim().isNotEmpty) { await widget.room.sendTextEvent( _msgCtrl.text.trim(), inReplyTo: widget.replyTo, ); } if (mounted) Navigator.of(context).pop(true); } catch (e) { setState(() { _sending = false; _error = e.toString().split('\n').first; }); } } @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); return Dialog( backgroundColor: pt.bg1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rLg), side: BorderSide(color: pt.border), ), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480, minWidth: 320), child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Title Row( children: [ Icon(Icons.attach_file_rounded, size: 18, color: pt.accent), const SizedBox(width: 8), Text( '${widget.files.length} Datei${widget.files.length == 1 ? '' : 'en'} senden', style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w600), ), const Spacer(), GestureDetector( onTap: () => Navigator.of(context).pop(false), child: Icon(Icons.close_rounded, size: 18, color: pt.fgDim), ), ], ), const SizedBox(height: 16), // File list ConstrainedBox( constraints: const BoxConstraints(maxHeight: 160), child: ListView.builder( shrinkWrap: true, itemCount: widget.files.length, itemBuilder: (ctx, i) => _FilePreview(file: widget.files[i], pt: pt), ), ), const SizedBox(height: 16), // Caption field TextField( controller: _msgCtrl, style: TextStyle(color: pt.fg, fontSize: 14), cursorColor: pt.accent, maxLines: 3, minLines: 1, decoration: InputDecoration( hintText: 'Nachricht hinzufügen (optional)', hintStyle: TextStyle(color: pt.fgDim, fontSize: 14), filled: true, fillColor: pt.bg2, border: OutlineInputBorder( borderRadius: BorderRadius.circular(pt.rBase), borderSide: BorderSide(color: pt.border), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(pt.rBase), borderSide: BorderSide(color: pt.border), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(pt.rBase), borderSide: BorderSide(color: pt.accent), ), contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), isDense: true, ), ), // Image quality toggle if (_hasImages) ...[ const SizedBox(height: 12), Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: pt.bg2, borderRadius: BorderRadius.circular(pt.rBase), border: Border.all(color: pt.border), ), child: Row( children: [ Icon(Icons.high_quality_outlined, size: 16, color: pt.fgMuted), const SizedBox(width: 8), Text('Hohe Qualität', style: TextStyle(color: pt.fg, fontSize: 13)), const Spacer(), Switch.adaptive( value: !_compress, onChanged: (v) => setState(() => _compress = !v), activeThumbColor: pt.accent, ), ], ), ), ], if (_error != null) ...[ const SizedBox(height: 8), Text(_error!, style: TextStyle(color: pt.danger, fontSize: 12)), ], const SizedBox(height: 16), // Actions Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: _sending ? null : () => Navigator.of(context).pop(false), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), ), const SizedBox(width: 8), FilledButton.icon( onPressed: _sending ? null : _send, style: FilledButton.styleFrom( backgroundColor: pt.accent, foregroundColor: pt.accentFg, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rBase), ), ), icon: _sending ? SizedBox( width: 14, height: 14, child: CircularProgressIndicator( strokeWidth: 2, color: pt.accentFg, ), ) : const Icon(Icons.send_rounded, size: 14), label: const Text('Senden'), ), ], ), ], ), ), ), ); } } class _FilePreview extends StatefulWidget { final File file; final PyramidTheme pt; const _FilePreview({required this.file, required this.pt}); @override State<_FilePreview> createState() => _FilePreviewState(); } class _FilePreviewState extends State<_FilePreview> { int? _bytes; @override void initState() { super.initState(); widget.file.length().then((b) { if (mounted) setState(() => _bytes = b); }); } @override Widget build(BuildContext context) { final filename = widget.file.path.split(Platform.pathSeparator).last; final mime = lookupMimeType(filename) ?? 'application/octet-stream'; final isImage = mime.startsWith('image/'); return Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: widget.pt.bg2, borderRadius: BorderRadius.circular(widget.pt.rBase), border: Border.all(color: widget.pt.border), ), child: Row( children: [ if (isImage) ClipRRect( borderRadius: BorderRadius.circular(6), child: Image.file(widget.file, width: 40, height: 40, fit: BoxFit.cover), ) else Container( width: 40, height: 40, decoration: BoxDecoration( color: widget.pt.bg3, borderRadius: BorderRadius.circular(6), ), child: Center( child: Icon(_mimeIcon(mime), size: 20, color: widget.pt.fgDim), ), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( filename, style: TextStyle(color: widget.pt.fg, fontSize: 13), overflow: TextOverflow.ellipsis, ), if (_bytes != null) Text( _formatSize(_bytes!), style: TextStyle(color: widget.pt.fgDim, fontSize: 11), ), ], ), ), ], ), ); } IconData _mimeIcon(String mime) { if (mime.startsWith('video/')) return Icons.videocam_outlined; if (mime.startsWith('audio/')) return Icons.audio_file_outlined; if (mime.contains('pdf')) return Icons.picture_as_pdf_outlined; if (mime.contains('zip') || mime.contains('tar') || mime.contains('gz')) { return Icons.folder_zip_outlined; } return Icons.insert_drive_file_outlined; } String _formatSize(int bytes) { if (bytes < 1024) return '$bytes B'; if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; } }