wip: Sicherungs-Commit aller Änderungen seit April + Arbeitsstruktur (CLAUDE.md, ROADMAP.md, PROGRESS.md, Autopilot)
6 Wochen uncommittete Arbeit (Voice-Channels, LiveKit-Manager, Settings-Modal u.v.m.) als ein WIP-Commit gesichert, damit nichts verloren geht und der Pi den aktuellen Stand klonen kann. Thematische Aufarbeitung: siehe ROADMAP M0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPrAGBxBT6GfPXzeWQ4AXb
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user