Files
pyramid/lib/features/chat/attachment_dialog.dart
Bernd Steckmeister 057ac1e4ea style: verbleibende flutter-analyze Deprecations beheben
withOpacity/activeColor/cacheExtent/PublicRoomsChunk durch die neuen
Flutter-3.44-Äquivalente ersetzt.
2026-07-03 09:40:10 +02:00

329 lines
11 KiB
Dart

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<File> files;
final Room room;
final Event? replyTo;
const AttachmentDialog({
super.key,
required this.files,
required this.room,
this.replyTo,
});
static Future<bool> show(
BuildContext context,
List<File> files,
Room room, {
Event? replyTo,
}) async {
// Snappy Scale+Fade-Eingangsanimation statt Standard-Einblendung.
return await showGeneralDialog<bool>(
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<AttachmentDialog> createState() => _AttachmentDialogState();
}
class _AttachmentDialogState extends State<AttachmentDialog> {
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<void> _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';
}
}