99f10c5210
Dart 3 erlaubt mehrere Wildcard-Parameter mit demselben Namen "_" in einem Scope, daher sind __ / ___ für ungenutzte Callback-Parameter (pageBuilder, errorBuilder etc.) unnötig. Rein mechanische Änderung, keine Verhaltensänderung. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
329 lines
11 KiB
Dart
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),
|
|
activeColor: 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';
|
|
}
|
|
}
|