daf4cf3dad
Sechster Durchgang derselben part/part-of-Technik wie bei den 5 vorigen Umbauten - reine Verschiebung, keine Logikänderung. document_viewer.dart schrumpft von 1773 auf 20 Zeilen (nur noch Bibliothekskopf), 34 Top-Level-Bloecke (31 Klassen/Enums + 3 lose Funktionen) auf 5 thematische Dateien unter lib/features/chat/document/ verteilt. Verifiziert per automatisiertem Blockvergleich gegen das Original und flutter analyze (weiterhin nur 1 bekannter Hinweis). voice_channel.dart und app_shell.dart bewusst nicht angefasst - haengen an der noch unentschiedenen Call-Fassade bzw. dem Bootstrap/Login-Pfad. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
320 lines
11 KiB
Dart
320 lines
11 KiB
Dart
part of '../document_viewer.dart';
|
|
|
|
class _ArchiveViewer extends StatefulWidget {
|
|
final Uint8List bytes;
|
|
final String filename;
|
|
final PyramidTheme pt;
|
|
const _ArchiveViewer({required this.bytes, required this.filename, required this.pt});
|
|
|
|
@override
|
|
State<_ArchiveViewer> createState() => _ArchiveViewerState();
|
|
}
|
|
class _ArchiveViewerState extends State<_ArchiveViewer> {
|
|
List<_ArchiveEntry>? _entries;
|
|
String? _error;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_decode();
|
|
}
|
|
|
|
void _decode() {
|
|
try {
|
|
final ext = widget.filename.contains('.') ? widget.filename.split('.').last.toLowerCase() : '';
|
|
Archive archive;
|
|
if (ext == 'tar') {
|
|
archive = TarDecoder().decodeBytes(widget.bytes);
|
|
} else if (ext == 'gz' || ext == 'tgz') {
|
|
final inner = GZipDecoder().decodeBytes(widget.bytes);
|
|
archive = TarDecoder().decodeBytes(inner);
|
|
} else {
|
|
archive = ZipDecoder().decodeBytes(widget.bytes);
|
|
}
|
|
|
|
final entries = archive.files.map((f) => _ArchiveEntry(name: f.name, size: f.size, isDir: !f.isFile)).toList()
|
|
..sort((a, b) {
|
|
if (a.isDir != b.isDir) return a.isDir ? -1 : 1;
|
|
return a.name.compareTo(b.name);
|
|
});
|
|
setState(() => _entries = entries);
|
|
} catch (e) {
|
|
setState(() => _error = e.toString());
|
|
}
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (_error != null) return _ErrorView(message: _error!, pt: widget.pt);
|
|
if (_entries == null) return Center(child: CircularProgressIndicator(strokeWidth: 2, color: widget.pt.accent));
|
|
|
|
final pt = widget.pt;
|
|
final entries = _entries!;
|
|
final totalBytes = entries.fold<int>(0, (s, e) => s + (e.isDir ? 0 : e.size));
|
|
|
|
return Column(
|
|
children: [
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
color: pt.bg1,
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.folder_zip_outlined, size: 15, color: pt.fgDim),
|
|
const SizedBox(width: 8),
|
|
Text('${entries.length} Einträge · ${_fmtSize(totalBytes)} unkomprimiert', style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: ListView.builder(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
itemCount: entries.length,
|
|
itemBuilder: (ctx, i) {
|
|
final e = entries[i];
|
|
return ListTile(
|
|
dense: true,
|
|
visualDensity: VisualDensity.compact,
|
|
leading: Icon(e.isDir ? Icons.folder_outlined : _fileIcon(e.name), size: 18, color: e.isDir ? pt.accent : pt.fgDim),
|
|
title: Text(e.name, style: TextStyle(color: pt.fg, fontSize: 13), overflow: TextOverflow.ellipsis),
|
|
trailing: e.isDir ? null : Text(_fmtSize(e.size), style: TextStyle(color: pt.fgDim, fontSize: 11)),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
IconData _fileIcon(String name) {
|
|
final ext = name.contains('.') ? name.split('.').last.toLowerCase() : '';
|
|
return switch (ext) {
|
|
'dart' || 'py' || 'js' || 'ts' || 'json' || 'xml' || 'yaml' || 'toml' => Icons.code_rounded,
|
|
'png' || 'jpg' || 'jpeg' || 'gif' || 'webp' || 'svg' => Icons.image_outlined,
|
|
'mp4' || 'mov' || 'avi' || 'mkv' => Icons.videocam_outlined,
|
|
'mp3' || 'ogg' || 'flac' || 'wav' => Icons.music_note_outlined,
|
|
'pdf' => Icons.picture_as_pdf_outlined,
|
|
'zip' || 'tar' || 'gz' || '7z' || 'rar' => Icons.folder_zip_outlined,
|
|
'md' || 'markdown' => Icons.article_outlined,
|
|
_ => Icons.insert_drive_file_outlined,
|
|
};
|
|
}
|
|
}
|
|
class _ArchiveEntry {
|
|
final String name;
|
|
final int size;
|
|
final bool isDir;
|
|
const _ArchiveEntry({required this.name, required this.size, required this.isDir});
|
|
}
|
|
Uint8List? _extractPsdThumbnail(Uint8List bytes) {
|
|
try {
|
|
if (bytes.length < 34) return null;
|
|
final data = ByteData.sublistView(bytes);
|
|
// Verify '8BPS' signature
|
|
if (bytes[0] != 0x38 || bytes[1] != 0x42 || bytes[2] != 0x50 || bytes[3] != 0x53) return null;
|
|
|
|
// PSD is big-endian throughout.
|
|
// Color Mode Data section length at offset 26.
|
|
final cmdLen = data.getUint32(26, Endian.big);
|
|
// Image Resources section starts after 26-byte header + 4-byte length field + cmdLen bytes.
|
|
final irPos = 30 + cmdLen;
|
|
if (irPos + 4 > bytes.length) return null;
|
|
final irLen = data.getUint32(irPos, Endian.big);
|
|
|
|
int pos = irPos + 4;
|
|
final irEnd = pos + irLen;
|
|
|
|
while (pos + 10 <= irEnd && pos + 10 <= bytes.length) {
|
|
// Signature: '8BIM'
|
|
if (bytes[pos] != 0x38 || bytes[pos+1] != 0x42 || bytes[pos+2] != 0x49 || bytes[pos+3] != 0x4D) break;
|
|
pos += 4;
|
|
|
|
final resourceId = data.getUint16(pos, Endian.big);
|
|
pos += 2;
|
|
|
|
// Pascal string: 1-byte length + string, total padded to even.
|
|
final nameLen = bytes[pos];
|
|
pos += (nameLen + 2) & ~1;
|
|
|
|
if (pos + 4 > bytes.length) break;
|
|
final dataSize = data.getUint32(pos, Endian.big);
|
|
pos += 4;
|
|
|
|
// Resource 1036 (Photoshop 5+) or 1033 (older) = Thumbnail
|
|
if ((resourceId == 0x040C || resourceId == 0x0409) && dataSize >= 28) {
|
|
final format = data.getUint32(pos, Endian.big);
|
|
if (format == 1) { // kJpegRGB
|
|
// Use dataSize-28 for the JPEG extent (more reliable than the
|
|
// compressedSize field at pos+20, which varies across PS versions).
|
|
final jpegStart = pos + 28;
|
|
final jpegEnd = (pos + dataSize).clamp(jpegStart, bytes.length);
|
|
if (jpegEnd > jpegStart) {
|
|
final thumb = Uint8List.sublistView(bytes, jpegStart, jpegEnd);
|
|
// Validate JPEG magic bytes (FF D8)
|
|
if (thumb.length >= 2 && thumb[0] == 0xFF && thumb[1] == 0xD8) {
|
|
return thumb;
|
|
}
|
|
// Some files need a forward scan for the actual JPEG start
|
|
for (var i = 0; i < thumb.length - 1; i++) {
|
|
if (thumb[i] == 0xFF && thumb[i + 1] == 0xD8) {
|
|
return Uint8List.sublistView(thumb, i);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Advance past this resource's data (padded to even).
|
|
final advance = (dataSize + 1) & ~1;
|
|
if (advance == 0 && dataSize == 0) { pos += 0; } else { pos += advance; }
|
|
}
|
|
return null;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
class _PsdThumbViewer extends StatelessWidget {
|
|
final Uint8List bytes;
|
|
final String filename;
|
|
final PyramidTheme pt;
|
|
const _PsdThumbViewer({required this.bytes, required this.filename, required this.pt});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final thumb = _extractPsdThumbnail(bytes);
|
|
if (thumb == null) {
|
|
return _ProprietaryViewer(filename: filename, bytes: bytes, pt: pt);
|
|
}
|
|
return Column(
|
|
children: [
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
|
color: pt.bg2,
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.info_outline_rounded, size: 14, color: pt.fgDim),
|
|
const SizedBox(width: 8),
|
|
Text('Eingebettete Vorschau (Vollbild nicht verfügbar)', style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: InteractiveViewer(
|
|
minScale: 0.5,
|
|
maxScale: 10,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Image.memory(thumb, fit: BoxFit.contain),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
class _ZipPreviewViewer extends StatefulWidget {
|
|
final Uint8List bytes;
|
|
final String filename;
|
|
final PyramidTheme pt;
|
|
const _ZipPreviewViewer({required this.bytes, required this.filename, required this.pt});
|
|
|
|
@override
|
|
State<_ZipPreviewViewer> createState() => _ZipPreviewViewerState();
|
|
}
|
|
class _ZipPreviewViewerState extends State<_ZipPreviewViewer> {
|
|
Uint8List? _preview;
|
|
bool _loaded = false;
|
|
bool _isImage = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_extract();
|
|
}
|
|
|
|
void _extract() {
|
|
try {
|
|
final archive = ZipDecoder().decodeBytes(widget.bytes);
|
|
// Priority search list
|
|
const candidates = [
|
|
'previews/preview.png', 'preview.png', 'thumbnail.png',
|
|
'thumbnails/thumbnail.png', 'preview.jpg', 'thumbnail.jpg',
|
|
];
|
|
ArchiveFile? found;
|
|
for (final path in candidates) {
|
|
found = archive.findFile(path);
|
|
if (found != null && found.isFile) break;
|
|
}
|
|
// Fallback: any file with preview/thumbnail in the name
|
|
if (found == null) {
|
|
for (final f in archive.files) {
|
|
if (!f.isFile) continue;
|
|
final n = f.name.toLowerCase();
|
|
if ((n.contains('preview') || n.contains('thumb')) &&
|
|
(n.endsWith('.png') || n.endsWith('.jpg') || n.endsWith('.jpeg'))) {
|
|
found = f;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (found != null) {
|
|
setState(() {
|
|
_preview = Uint8List.fromList(found!.content as List<int>);
|
|
_isImage = true;
|
|
_loaded = true;
|
|
});
|
|
} else {
|
|
setState(() => _loaded = true);
|
|
}
|
|
} catch (_) {
|
|
setState(() => _loaded = true);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final pt = widget.pt;
|
|
if (!_loaded) {
|
|
return Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent));
|
|
}
|
|
if (_isImage && _preview != null) {
|
|
return Column(
|
|
children: [
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
|
color: pt.bg2,
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.info_outline_rounded, size: 14, color: pt.fgDim),
|
|
const SizedBox(width: 8),
|
|
Text('Eingebettete Vorschau', style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: InteractiveViewer(
|
|
minScale: 0.5,
|
|
maxScale: 10,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Image.memory(_preview!, fit: BoxFit.contain),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
// No preview image found — fall back to archive listing
|
|
return _ArchiveViewer(bytes: widget.bytes, filename: widget.filename, pt: pt);
|
|
}
|
|
}
|