refactor: Gott-Datei document_viewer.dart in 5 Dateien aufgeteilt
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>
This commit is contained in:
@@ -1169,3 +1169,7 @@
|
||||
2026-07-03 11:01 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-03 11:03 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-03 11:03 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-03 12:34 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
2026-07-03 12:57 [Write] /home/steggi/pyramid/lib/features/chat/document_viewer.dart
|
||||
2026-07-03 12:58 [Edit] /home/steggi/pyramid/ROADMAP.md
|
||||
2026-07-03 12:58 [Edit] /home/steggi/pyramid/PROGRESS.md
|
||||
|
||||
+72
@@ -13,6 +13,78 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format:
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-03 – M2: Gott-Datei `document_viewer.dart` aufgeteilt (5 Dateien)
|
||||
|
||||
**Erledigt:** Vor dem Weiterarbeiten wie immer Arbeitszyklus durchlaufen
|
||||
(`PROGRESS.md`/`ROADMAP.md` gelesen, `flutter doctor` geprüft – GUI-Lücken
|
||||
auf dem Pi unverändert). Die Ist-Analyse aus M2 hatte ursprünglich 8 große
|
||||
Dateien gefunden, aber nur 5 wurden als „Gott-Dateien" tatsächlich
|
||||
aufgeteilt (`settings_modal.dart`, `message_group.dart`, `rooms_panel.dart`,
|
||||
`space_admin_dialog.dart`, `chat_view.dart`); `document_viewer.dart` (1773
|
||||
Zeilen, 31 Klassen/Enums + 3 lose Top-Level-Helfer) war übrig, ist aber
|
||||
inhaltlich genau derselbe Fall (viele unabhängige Widget-Klassen in einer
|
||||
Datei) und – anders als `voice_channel.dart`/`app_shell.dart` – reines
|
||||
Medien-/PDF-Anzeige-Feature ohne Berührung mit Login/Session/Verschlüsselung
|
||||
oder der noch unentschiedenen Call-Fassade. Deshalb mit derselben
|
||||
bewährten Technik aufgeteilt:
|
||||
|
||||
- `part`/`part of` wie bei den 5 vorigen Umbauten, per Skript extrahiert
|
||||
(Klammer-Zählung für Blockgrenzen inkl. der 3 losen Top-Level-Funktionen
|
||||
`_saveBytes`, `_extractPsdThumbnail`, `_detectType` – nicht nur
|
||||
`class`/`enum`, das wurde diesmal zusätzlich automatisiert gesucht, um
|
||||
keine lose Funktion zu übersehen).
|
||||
- 5 neue Dateien unter `lib/features/chat/document/`:
|
||||
`document_viewer_core.dart` (`DocumentViewer`-Dispatcher, `_DocHeader`,
|
||||
`_ExtBadge`, `_DocContent`, `_DocType`-Enum + `_detectType`, `_ErrorView`,
|
||||
`_UnsupportedViewer`, `_FileFallback`), `document_viewer_pdf.dart`
|
||||
(größter Teil: `_RenderCache`, `PdfInlinePreview`+State für die
|
||||
Chat-Inline-Vorschau, `_NavArrow`, `_saveBytes`, `_PdfFullViewer`+State,
|
||||
`_PdfFullscreenViewer`+State, `_FsBtn`, `_ThumbnailStrip`+State),
|
||||
`document_viewer_text.dart` (SVG/Markdown/Text/Office-Text-Extraktion),
|
||||
`document_viewer_archive.dart` (ZIP/TAR-Archiv-Browser, PSD-Thumbnail-
|
||||
Extraktion inkl. `_extractPsdThumbnail`, Sketch/XD-ZIP-Preview),
|
||||
`document_viewer_misc.dart` (`_ProprietaryViewer`-Infokarte für nicht
|
||||
darstellbare Formate). `document_viewer.dart` selbst: nur noch
|
||||
Bibliothekskopf (Imports + 5 `part`-Direktiven, 20 Zeilen).
|
||||
- Verifikation wie bei den 5 vorigen Umbauten: automatisierte
|
||||
Klammer-Zählung fand alle 34 Top-Level-Blöcke (31 Klassen/Enums + 3 lose
|
||||
Funktionen), alle wortwörtlich in den neuen Dateien wiedergefunden,
|
||||
`class`/`enum`-Anzahl (31) vorher/nachher identisch, `flutter analyze`
|
||||
unverändert bei 1 bekanntem Hinweis (`chat_provider.dart:42`). Einzige
|
||||
externe Importstelle (`message_group.dart`) bleibt unverändert gültig, da
|
||||
`document_viewer.dart` weiterhin der Bibliothekseinstieg ist.
|
||||
- **Bewusst NICHT angefasst:** `voice_channel.dart` (1123 Zeilen) und
|
||||
`app_shell.dart` (1073 Zeilen) – auch große Dateien mit vielen Klassen,
|
||||
aber `voice_channel.dart` gehört zur noch unentschiedenen Call-Fassade
|
||||
(siehe Fragen an Bernd unten) und `app_shell.dart` enthält den
|
||||
Bootstrap-Trigger (`_triggerBootstrap`) für Cross-Signing/Key-Backup –
|
||||
laut CLAUDE.md heiliger Bereich. Eine reine Datei-Aufteilung wäre zwar
|
||||
vermutlich genauso risikoarm wie bei den anderen 6, aber ohne GUI-Test auf
|
||||
dem Pi wollte ich das Risiko nicht zusätzlich zum ohnehin schon
|
||||
aufgelaufenen UNGETESTET-Stapel erhöhen, bevor nicht wenigstens einmal ein
|
||||
PC-Durchlauf stattgefunden hat.
|
||||
|
||||
**Offen/Nächster Schritt:** UNGETESTET (Pi) – siehe ROADMAP-Eintrag oben,
|
||||
gehört zum selben ausstehenden Windows-Praxistest wie die 5 vorigen
|
||||
Datei-Aufteilungen (PDF/Text/Archiv/Bild im Chat öffnen, Vollbild-PDF-Viewer
|
||||
inkl. Thumbnail-Leiste und Speichern-Button prüfen). Danach bleibt exakt
|
||||
dieselbe Blockade wie im Eintrag direkt darunter: kein M1/M2-Punkt mehr
|
||||
ohne Bernds Antwort (Call-Pilot, SQLCipher-Priorität) oder PC/GUI-Zugriff
|
||||
sicher bearbeitbar.
|
||||
|
||||
**Stolperfallen:** Anders als bei den 5 vorigen Umbauten gab es diesmal 3
|
||||
lose Top-Level-Funktionen zusätzlich zu den Klassen/Enums
|
||||
(`_saveBytes`/`_extractPsdThumbnail`/`_detectType`) – eine reine
|
||||
`grep -E '^(class|enum) '`-Suche hätte sie übersehen und stillschweigend aus
|
||||
der neuen Datei-Struktur fallen lassen. Deshalb diesmal zusätzlich per
|
||||
Diff aller durch Klammer-Zählung abgedeckten vs. nicht abgedeckten
|
||||
Zeilenbereiche geprüft, dass keine „Lücke" zwischen zwei Klassenblöcken
|
||||
mehr als Kommentar/Leerzeile ist. Für künftige Datei-Aufteilungen (z. B.
|
||||
`voice_channel.dart`/`app_shell.dart`, falls Bernd grünes Licht gibt): immer
|
||||
zuerst auf lose Top-Level-Funktionen prüfen, nicht nur auf Klassen/Enums.
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-03 – Session-Check: weiterhin kein sicher bearbeitbarer Punkt auf dem Pi
|
||||
|
||||
**Erledigt:** Vor dem Weiterarbeiten Arbeitszyklus komplett durchlaufen: `PROGRESS.md`
|
||||
|
||||
+15
-7
@@ -77,7 +77,7 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
||||
- [ ] Einheitliches State-Management (Riverpod konsequent, keine Misch-Patterns)
|
||||
- [x] Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen
|
||||
(PROGRESS.md 2026-07-03: `message_bubble.dart` tot entfernt, doppelte
|
||||
`_formatTime` in `message_group.dart` zusammengeführt; alle 5 in der
|
||||
`_formatTime` in `message_group.dart` zusammengeführt; alle 6 in der
|
||||
Ist-Analyse gefundenen Gott-Dateien per `part`/`part of` in
|
||||
thematische Dateien aufgeteilt – `widgets/settings_modal.dart`
|
||||
(5541→270 Zeilen, 12 Teile unter `widgets/settings/`),
|
||||
@@ -86,12 +86,20 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
||||
(2555→31 Zeilen, 8 Teile unter `features/rooms/panel/`),
|
||||
`features/spaces/space_admin_dialog.dart` (2278→19 Zeilen, 6 Teile
|
||||
unter `features/spaces/admin/`), `features/chat/chat_view.dart`
|
||||
(2116→33 Zeilen, 6 Teile unter `features/chat/view/`) – jeweils
|
||||
verifiziert per automatisiertem Blockvergleich gegen das Original und
|
||||
`flutter analyze`, siehe PROGRESS.md-Einträge. UNGETESTET (Pi) im
|
||||
Sinne von „nicht in echter UI gesehen" – nächster Windows-Lauf sollte
|
||||
alle 5 betroffenen Bereiche einmal durchklicken, siehe „Fragen an
|
||||
Bernd"/Nächster-Schritt in den jeweiligen PROGRESS.md-Einträgen)
|
||||
(2116→33 Zeilen, 6 Teile unter `features/chat/view/`),
|
||||
`features/chat/document_viewer.dart` (1773→20 Zeilen, 5 Teile unter
|
||||
`features/chat/document/`) – jeweils verifiziert per automatisiertem
|
||||
Blockvergleich gegen das Original und `flutter analyze`, siehe
|
||||
PROGRESS.md-Einträge. `features/call/voice_channel.dart` (1123 Zeilen)
|
||||
und `layout/app_shell.dart` (1073 Zeilen) bewusst NICHT mit angefasst –
|
||||
beide hängen eng mit der noch unentschiedenen Call-Fassade bzw. dem
|
||||
Bootstrap/Login-Pfad zusammen, siehe „Call-Schicht entwirren" unten.
|
||||
UNGETESTET (Pi) im Sinne von „nicht in echter UI gesehen" – nächster
|
||||
Windows-Lauf sollte alle 6 betroffenen Bereiche einmal durchklicken
|
||||
(zusätzlich zu den 5 unten: eine PDF/Text/Archiv/Bild-Datei im Chat
|
||||
öffnen, Vollbild-PDF-Viewer inkl. Thumbnail-Leiste und Speichern-Button
|
||||
prüfen), siehe „Fragen an Bernd"/Nächster-Schritt in den jeweiligen
|
||||
PROGRESS.md-Einträgen)
|
||||
- [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit
|
||||
|
||||
## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
part of '../document_viewer.dart';
|
||||
|
||||
enum _DocType { pdf, svg, text, code, markdown, office, archive, psdThumb, zipPreview, proprietary, unknown }
|
||||
_DocType _detectType(String filename) {
|
||||
final ext = filename.contains('.') ? filename.split('.').last.toLowerCase() : '';
|
||||
return switch (ext) {
|
||||
// AI/EPS files embed a full PDF when saved with PDF compatibility
|
||||
'pdf' || 'ai' || 'eps' => _DocType.pdf,
|
||||
'svg' => _DocType.svg,
|
||||
'md' || 'markdown' || 'rst' => _DocType.markdown,
|
||||
'zip' || 'tar' || 'gz' || 'tgz' || 'tar.gz' => _DocType.archive,
|
||||
'txt' || 'log' || 'csv' || 'tsv' ||
|
||||
'rtf' || 'tex' || 'srt' || 'vtt' || 'nfo' => _DocType.text,
|
||||
// ODF formats are ZIP+XML, same extraction approach as OOXML
|
||||
'docx' || 'xlsx' || 'pptx' ||
|
||||
'odt' || 'odp' || 'ods' => _DocType.office,
|
||||
'dart' || 'py' || 'js' || 'mjs' || 'cjs' || 'ts' || 'jsx' || 'tsx' ||
|
||||
'json' || 'jsonl' || 'ndjson' || 'yaml' || 'yml' || 'xml' || 'plist' ||
|
||||
'html' || 'htm' || 'css' || 'scss' || 'sass' || 'less' ||
|
||||
'vue' || 'svelte' || 'astro' ||
|
||||
'sh' || 'bash' || 'zsh' || 'fish' || 'ps' || 'ps1' || 'bat' || 'cmd' ||
|
||||
'kt' || 'kts' || 'gradle' || 'java' || 'swift' || 'go' || 'rs' ||
|
||||
'cpp' || 'cc' || 'cxx' || 'c' || 'h' || 'hpp' || 'hxx' || 'm' || 'mm' ||
|
||||
'cs' || 'rb' || 'php' || 'r' || 'lua' || 'pl' || 'pm' || 'scala' ||
|
||||
'clj' || 'ex' || 'exs' || 'erl' || 'hs' || 'jl' || 'nim' || 'zig' ||
|
||||
'toml' || 'ini' || 'conf' || 'cfg' || 'env' || 'editorconfig' ||
|
||||
'properties' || 'diff' || 'patch' || 'gitignore' || 'dockerfile' ||
|
||||
'sql' || 'graphql' || 'gql' || 'proto' => _DocType.code,
|
||||
// PSD/PSB embed a JPEG thumbnail in Image Resource 1036
|
||||
'psd' || 'psb' => _DocType.psdThumb,
|
||||
// Sketch and XD are ZIP archives containing a preview PNG
|
||||
'sketch' || 'xd' => _DocType.zipPreview,
|
||||
// Truly proprietary — no extractable preview
|
||||
'afphoto' || 'afdesign' || 'afpub' ||
|
||||
'indd' || 'inx' || 'fig' ||
|
||||
'doc' || 'ppt' || 'xls' => _DocType.proprietary,
|
||||
_ => _DocType.unknown,
|
||||
};
|
||||
}
|
||||
class DocumentViewer extends StatelessWidget {
|
||||
final String filename;
|
||||
final Uint8List bytes;
|
||||
final String senderName;
|
||||
final int initialPage;
|
||||
|
||||
const DocumentViewer({
|
||||
super.key,
|
||||
required this.filename,
|
||||
required this.bytes,
|
||||
required this.senderName,
|
||||
this.initialPage = 1,
|
||||
});
|
||||
|
||||
static bool supportsPreview(String filename) =>
|
||||
_detectType(filename) != _DocType.unknown;
|
||||
|
||||
/// True for formats rendered inline in chat (pdf/svg types, including ai/eps).
|
||||
static bool isInlinePreviewable(String filename) {
|
||||
final t = _detectType(filename);
|
||||
return t == _DocType.pdf || t == _DocType.svg;
|
||||
}
|
||||
|
||||
/// True for formats that show a card inline in chat (needs download-on-tap).
|
||||
static bool showsInlineCard(String filename) {
|
||||
final t = _detectType(filename);
|
||||
return t == _DocType.proprietary || t == _DocType.psdThumb || t == _DocType.zipPreview;
|
||||
}
|
||||
|
||||
/// Kept for compatibility — same as showsInlineCard.
|
||||
static bool isProprietaryFormat(String filename) => showsInlineCard(filename);
|
||||
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required String filename,
|
||||
required Uint8List bytes,
|
||||
required String senderName,
|
||||
int initialPage = 1,
|
||||
}) {
|
||||
return showGeneralDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: 'Schließen',
|
||||
barrierColor: Colors.black.withAlpha(178),
|
||||
transitionDuration: const Duration(milliseconds: 180),
|
||||
transitionBuilder: (ctx, anim, a, child) =>
|
||||
FadeTransition(opacity: anim, child: child),
|
||||
pageBuilder: (ctx, a, b) => DocumentViewer(
|
||||
filename: filename,
|
||||
bytes: bytes,
|
||||
senderName: senderName,
|
||||
initialPage: initialPage,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final type = _detectType(filename);
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final maxW = (size.width - 48).clamp(300.0, 1100.0);
|
||||
final maxH = (size.height - 48).clamp(300.0, 900.0);
|
||||
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: maxW,
|
||||
height: maxH,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg0,
|
||||
borderRadius: BorderRadius.circular(pt.rLg),
|
||||
border: Border.all(color: pt.border),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withAlpha(80), blurRadius: 32, offset: const Offset(0, 8))],
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: Column(
|
||||
children: [
|
||||
_DocHeader(filename: filename, senderName: senderName, pt: pt),
|
||||
Expanded(
|
||||
child: _DocContent(
|
||||
filename: filename,
|
||||
bytes: bytes,
|
||||
type: type,
|
||||
pt: pt,
|
||||
initialPage: initialPage,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
class _DocHeader extends StatelessWidget {
|
||||
final String filename;
|
||||
final String senderName;
|
||||
final PyramidTheme pt;
|
||||
const _DocHeader({required this.filename, required this.senderName, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ext = filename.contains('.') ? filename.split('.').last.toUpperCase() : '';
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg1,
|
||||
border: Border(bottom: BorderSide(color: pt.border)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_ExtBadge(ext: ext, pt: pt),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(filename, style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600), overflow: TextOverflow.ellipsis),
|
||||
Text(senderName, style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: Icon(Icons.close_rounded, color: pt.fgMuted, size: 20),
|
||||
tooltip: 'Schließen',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
class _ExtBadge extends StatelessWidget {
|
||||
final String ext;
|
||||
final PyramidTheme pt;
|
||||
const _ExtBadge({required this.ext, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final label = ext.length > 4 ? ext.substring(0, 4) : ext;
|
||||
return Container(
|
||||
width: 44, height: 44,
|
||||
decoration: BoxDecoration(color: pt.accentSoft, borderRadius: BorderRadius.circular(pt.rSm)),
|
||||
child: Center(
|
||||
child: Text(label, style: TextStyle(color: pt.accent, fontSize: label.length > 3 ? 10 : 12, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
class _DocContent extends StatelessWidget {
|
||||
final String filename;
|
||||
final Uint8List bytes;
|
||||
final _DocType type;
|
||||
final PyramidTheme pt;
|
||||
final int initialPage;
|
||||
const _DocContent({required this.filename, required this.bytes, required this.type, required this.pt, required this.initialPage});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return switch (type) {
|
||||
_DocType.pdf => _PdfFullViewer(bytes: bytes, filename: filename, pt: pt, initialPage: initialPage),
|
||||
_DocType.svg => _SvgViewer(bytes: bytes, pt: pt),
|
||||
_DocType.markdown => _MarkdownViewer(bytes: bytes, pt: pt),
|
||||
_DocType.text => _TextViewer(bytes: bytes, pt: pt, mono: false),
|
||||
_DocType.code => _TextViewer(bytes: bytes, pt: pt, mono: true),
|
||||
_DocType.office => _OfficeTextViewer(bytes: bytes, filename: filename, pt: pt),
|
||||
_DocType.archive => _ArchiveViewer(bytes: bytes, filename: filename, pt: pt),
|
||||
_DocType.psdThumb => _PsdThumbViewer(bytes: bytes, filename: filename, pt: pt),
|
||||
_DocType.zipPreview => _ZipPreviewViewer(bytes: bytes, filename: filename, pt: pt),
|
||||
_DocType.proprietary => _ProprietaryViewer(filename: filename, bytes: bytes, pt: pt),
|
||||
_DocType.unknown => _UnsupportedViewer(pt: pt),
|
||||
};
|
||||
}
|
||||
}
|
||||
class _ErrorView extends StatelessWidget {
|
||||
final String message;
|
||||
final PyramidTheme pt;
|
||||
const _ErrorView({required this.message, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline_rounded, size: 32, color: pt.danger),
|
||||
const SizedBox(height: 8),
|
||||
Text('Fehler beim Laden', style: TextStyle(color: pt.danger, fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 6),
|
||||
Text(message, style: TextStyle(color: pt.fgMuted, fontSize: 12), textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
class _UnsupportedViewer extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
const _UnsupportedViewer({required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.preview_outlined, size: 48, color: pt.fgDim),
|
||||
const SizedBox(height: 12),
|
||||
Text('Vorschau nicht verfügbar', style: TextStyle(color: pt.fgDim, fontSize: 15)),
|
||||
const SizedBox(height: 4),
|
||||
Text('Proprietäres Format — bitte herunterladen.', style: TextStyle(color: pt.fgMuted, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
class _FileFallback extends StatelessWidget {
|
||||
final String filename;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback? onDownload;
|
||||
const _FileFallback({required this.filename, required this.pt, this.onDownload});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: 4, bottom: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg3,
|
||||
borderRadius: BorderRadius.circular(pt.rBase),
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 36, height: 36,
|
||||
decoration: BoxDecoration(color: pt.accentSoft, borderRadius: BorderRadius.circular(pt.rSm)),
|
||||
child: Icon(Icons.picture_as_pdf_outlined, size: 20, color: pt.accent),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(filename, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500), maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
if (onDownload != null)
|
||||
IconButton(
|
||||
onPressed: onDownload,
|
||||
icon: Icon(Icons.download_rounded, size: 20, color: pt.fgMuted),
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
part of '../document_viewer.dart';
|
||||
|
||||
class _ProprietaryViewer extends StatelessWidget {
|
||||
final String filename;
|
||||
final Uint8List bytes;
|
||||
final PyramidTheme pt;
|
||||
const _ProprietaryViewer({required this.filename, required this.bytes, required this.pt});
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
IconData _iconFor(String ext) => switch (ext) {
|
||||
'psd' || 'psb' => Icons.photo_camera_outlined,
|
||||
'ai' || 'eps' => Icons.gesture_outlined,
|
||||
'indd' || 'inx' => Icons.menu_book_outlined,
|
||||
'afphoto' || 'afdesign' || 'afpub' => Icons.palette_outlined,
|
||||
'sketch' || 'fig' || 'xd' => Icons.design_services_outlined,
|
||||
'doc' => Icons.description_outlined,
|
||||
'ppt' => Icons.slideshow_outlined,
|
||||
'xls' => Icons.table_chart_outlined,
|
||||
_ => Icons.insert_drive_file_outlined,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ext = filename.contains('.') ? filename.split('.').last.toLowerCase() : '';
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 80, height: 80,
|
||||
decoration: BoxDecoration(color: pt.accentSoft, borderRadius: BorderRadius.circular(pt.rBase)),
|
||||
child: Icon(_iconFor(ext), size: 40, color: pt.accent),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
filename,
|
||||
style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(_fmtSize(bytes.length), style: TextStyle(color: pt.fgDim, fontSize: 13)),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg2,
|
||||
borderRadius: BorderRadius.circular(pt.rSm),
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.info_outline_rounded, size: 16, color: pt.fgDim),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Format kann nicht nativ dargestellt werden',
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
part of '../document_viewer.dart';
|
||||
|
||||
class _RenderCache {
|
||||
static const _max = 80;
|
||||
static final _data = <String, Uint8List>{};
|
||||
|
||||
static String _k(String name, int sz, int page, int w, int h) =>
|
||||
'$name\x00$sz\x00$page\x00${w}x$h';
|
||||
|
||||
static Uint8List? get(String name, int sz, int page, int w, int h) =>
|
||||
_data[_k(name, sz, page, w, h)];
|
||||
|
||||
static void put(String name, int sz, int page, int w, int h, Uint8List img) {
|
||||
if (_data.length >= _max) _data.remove(_data.keys.first);
|
||||
_data[_k(name, sz, page, w, h)] = img;
|
||||
}
|
||||
}
|
||||
Future<void> _saveBytes(BuildContext context, Uint8List bytes, String filename) async {
|
||||
try {
|
||||
Directory? dir;
|
||||
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
|
||||
dir = await getDownloadsDirectory();
|
||||
}
|
||||
dir ??= await getTemporaryDirectory();
|
||||
final ext = filename.contains('.') ? filename.split('.').last : 'bin';
|
||||
final name = 'pyramid_${DateTime.now().millisecondsSinceEpoch}.$ext';
|
||||
final file = File('${dir.path}/$name');
|
||||
await file.writeAsBytes(bytes);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Gespeichert: ${file.path}'), duration: const Duration(seconds: 4)),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
class PdfInlinePreview extends StatefulWidget {
|
||||
final Uint8List bytes;
|
||||
final String filename;
|
||||
final PyramidTheme pt;
|
||||
final String senderName;
|
||||
final VoidCallback? onDownload;
|
||||
|
||||
const PdfInlinePreview({
|
||||
super.key,
|
||||
required this.bytes,
|
||||
required this.filename,
|
||||
required this.pt,
|
||||
required this.senderName,
|
||||
this.onDownload,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PdfInlinePreview> createState() => _PdfInlinePreviewState();
|
||||
}
|
||||
class _PdfInlinePreviewState extends State<PdfInlinePreview> {
|
||||
PdfDocument? _doc;
|
||||
int _currentPage = 1;
|
||||
int _pageCount = 0;
|
||||
double? _pageAspect; // width/height
|
||||
final Map<int, Uint8List?> _cache = {};
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
bool _rendering = false;
|
||||
|
||||
static const _previewW = 280.0;
|
||||
static const _renderScale = 2.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_init();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
try {
|
||||
final doc = await PdfDocument.openData(widget.bytes);
|
||||
if (!mounted) { await doc.close(); return; }
|
||||
_doc = doc;
|
||||
_pageCount = doc.pagesCount;
|
||||
setState(() => _loading = false);
|
||||
await _ensurePage(1);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _loading = false; _error = e.toString().split('\n').first; });
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ensurePage(int n) async {
|
||||
if (_cache.containsKey(n) || _rendering) return;
|
||||
_rendering = true;
|
||||
try {
|
||||
final page = await _doc!.getPage(n);
|
||||
if (mounted && _pageAspect == null) {
|
||||
setState(() => _pageAspect = page.width / page.height);
|
||||
}
|
||||
final rw = (page.width * _renderScale).round();
|
||||
final rh = (page.height * _renderScale).round();
|
||||
final hit = _RenderCache.get(widget.filename, widget.bytes.length, n, rw, rh);
|
||||
if (hit != null) {
|
||||
await page.close();
|
||||
if (mounted) setState(() => _cache[n] = hit);
|
||||
return;
|
||||
}
|
||||
final img = await page.render(
|
||||
width: page.width * _renderScale,
|
||||
height: page.height * _renderScale,
|
||||
format: PdfPageImageFormat.jpeg,
|
||||
backgroundColor: '#ffffff',
|
||||
);
|
||||
await page.close();
|
||||
if (img?.bytes != null) {
|
||||
_RenderCache.put(widget.filename, widget.bytes.length, n, rw, rh, img!.bytes);
|
||||
}
|
||||
if (mounted) setState(() => _cache[n] = img?.bytes);
|
||||
} catch (_) {
|
||||
_cache[n] = null;
|
||||
} finally {
|
||||
_rendering = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _goTo(int page) {
|
||||
if (page < 1 || page > _pageCount) return;
|
||||
setState(() => _currentPage = page);
|
||||
_ensurePage(page);
|
||||
_ensurePage((page + 1).clamp(1, _pageCount));
|
||||
}
|
||||
|
||||
void _openFull() {
|
||||
DocumentViewer.show(
|
||||
context,
|
||||
filename: widget.filename,
|
||||
bytes: widget.bytes,
|
||||
senderName: widget.senderName,
|
||||
initialPage: _currentPage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_doc?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
final radius = BorderRadius.circular(pt.rBase);
|
||||
|
||||
if (_loading) {
|
||||
return Container(
|
||||
width: _previewW,
|
||||
// Match the typical loaded A4 height so the preview doesn't jump (and
|
||||
// shift the scroll position) once the first page finishes rendering.
|
||||
height: (_previewW / 0.707).clamp(160.0, 400.0),
|
||||
margin: const EdgeInsets.only(top: 4, bottom: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg2,
|
||||
borderRadius: radius,
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
child: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)),
|
||||
);
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return _FileFallback(filename: widget.filename, pt: pt, onDownload: widget.onDownload);
|
||||
}
|
||||
|
||||
final pageBytes = _cache[_currentPage];
|
||||
final aspect = _pageAspect ?? 0.707; // A4 fallback
|
||||
final imgH = (_previewW / aspect).clamp(160.0, 400.0);
|
||||
|
||||
return Listener(
|
||||
onPointerSignal: (event) {
|
||||
if (event is PointerScrollEvent) {
|
||||
GestureBinding.instance.pointerSignalResolver.register(event, (resolved) {
|
||||
final dy = (resolved as PointerScrollEvent).scrollDelta.dy;
|
||||
if (dy > 0) { _goTo(_currentPage + 1); }
|
||||
else if (dy < 0) { _goTo(_currentPage - 1); }
|
||||
});
|
||||
}
|
||||
},
|
||||
child: GestureDetector(
|
||||
onTap: _openFull,
|
||||
onHorizontalDragEnd: (d) {
|
||||
if ((d.primaryVelocity ?? 0) < -200) _goTo(_currentPage + 1);
|
||||
if ((d.primaryVelocity ?? 0) > 200) _goTo(_currentPage - 1);
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 4, bottom: 4),
|
||||
width: _previewW,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: radius,
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Page image
|
||||
Container(
|
||||
width: _previewW,
|
||||
height: imgH,
|
||||
color: Colors.white,
|
||||
child: pageBytes != null
|
||||
? Image.memory(pageBytes, width: _previewW, height: imgH, fit: BoxFit.cover, alignment: Alignment.topCenter)
|
||||
: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)),
|
||||
),
|
||||
// Footer
|
||||
Container(
|
||||
color: pt.bg2,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.picture_as_pdf_outlined, size: 14, color: pt.fgDim),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.filename,
|
||||
style: TextStyle(color: pt.fg, fontSize: 12, fontWeight: FontWeight.w500),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (_pageCount > 1) ...[
|
||||
_NavArrow(
|
||||
icon: Icons.chevron_left_rounded,
|
||||
enabled: _currentPage > 1,
|
||||
pt: pt,
|
||||
onTap: () => _goTo(_currentPage - 1),
|
||||
),
|
||||
Text(
|
||||
'$_currentPage/$_pageCount',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 11, fontFeatures: const [FontFeature.tabularFigures()]),
|
||||
),
|
||||
_NavArrow(
|
||||
icon: Icons.chevron_right_rounded,
|
||||
enabled: _currentPage < _pageCount,
|
||||
pt: pt,
|
||||
onTap: () => _goTo(_currentPage + 1),
|
||||
),
|
||||
],
|
||||
if (widget.onDownload != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
GestureDetector(
|
||||
onTap: widget.onDownload,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: Icon(Icons.download_rounded, size: 16, color: pt.fgMuted),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
class _NavArrow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final bool enabled;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback onTap;
|
||||
const _NavArrow({required this.icon, required this.enabled, required this.pt, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
onTap: enabled ? onTap : null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Icon(icon, size: 18, color: enabled ? pt.fg : pt.fgDim),
|
||||
),
|
||||
);
|
||||
}
|
||||
class _PdfFullViewer extends StatefulWidget {
|
||||
final Uint8List bytes;
|
||||
final String filename;
|
||||
final PyramidTheme pt;
|
||||
final int initialPage;
|
||||
const _PdfFullViewer({required this.bytes, required this.filename, required this.pt, required this.initialPage});
|
||||
|
||||
@override
|
||||
State<_PdfFullViewer> createState() => _PdfFullViewerState();
|
||||
}
|
||||
class _PdfFullViewerState extends State<_PdfFullViewer> {
|
||||
late final PdfController _ctrl;
|
||||
PdfDocument? _thumbDoc;
|
||||
final Map<int, Uint8List?> _thumbs = {};
|
||||
int _pageCount = 0;
|
||||
int _currentPage = 1;
|
||||
bool _animating = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentPage = widget.initialPage;
|
||||
_ctrl = PdfController(
|
||||
document: PdfDocument.openData(widget.bytes),
|
||||
initialPage: widget.initialPage,
|
||||
);
|
||||
_ctrl.pageListenable.addListener(_onPage);
|
||||
_initThumbs();
|
||||
}
|
||||
|
||||
Future<void> _initThumbs() async {
|
||||
final doc = await PdfDocument.openData(widget.bytes);
|
||||
if (!mounted) { await doc.close(); return; }
|
||||
_thumbDoc = doc;
|
||||
if (!mounted) { await doc.close(); _thumbDoc = null; return; }
|
||||
setState(() => _pageCount = doc.pagesCount);
|
||||
for (var i = 1; i <= doc.pagesCount; i++) {
|
||||
_renderThumb(i);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _renderThumb(int n) async {
|
||||
final doc = _thumbDoc;
|
||||
if (doc == null) return;
|
||||
try {
|
||||
final page = await doc.getPage(n);
|
||||
final rw = (page.width * 0.12).round();
|
||||
final rh = (page.height * 0.12).round();
|
||||
final hit = _RenderCache.get(widget.filename, widget.bytes.length, n, rw, rh);
|
||||
if (hit != null) {
|
||||
await page.close();
|
||||
if (mounted) setState(() => _thumbs[n] = hit);
|
||||
return;
|
||||
}
|
||||
final img = await page.render(
|
||||
width: page.width * 0.12,
|
||||
height: page.height * 0.12,
|
||||
format: PdfPageImageFormat.jpeg,
|
||||
backgroundColor: '#ffffff',
|
||||
);
|
||||
await page.close();
|
||||
if (img?.bytes != null) {
|
||||
_RenderCache.put(widget.filename, widget.bytes.length, n, rw, rh, img!.bytes);
|
||||
}
|
||||
if (mounted) setState(() => _thumbs[n] = img?.bytes);
|
||||
} catch (_) {
|
||||
_thumbs[n] = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onPage() => setState(() => _currentPage = _ctrl.page);
|
||||
|
||||
void _onScrollPage(int delta) {
|
||||
if (_animating) return;
|
||||
final pageCount = _ctrl.pagesCount ?? _pageCount;
|
||||
if (pageCount == 0) return;
|
||||
final target = (_currentPage + delta).clamp(1, pageCount);
|
||||
if (target == _currentPage) return;
|
||||
_animating = true;
|
||||
_ctrl.animateToPage(
|
||||
target,
|
||||
duration: const Duration(milliseconds: 280),
|
||||
curve: Curves.easeInOut,
|
||||
).then((_) { if (mounted) setState(() => _animating = false); });
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.pageListenable.removeListener(_onPage);
|
||||
_ctrl.dispose();
|
||||
_thumbDoc?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Listener(
|
||||
onPointerSignal: (event) {
|
||||
if (event is PointerScrollEvent) {
|
||||
if (event.scrollDelta.dy > 0) { _onScrollPage(1); }
|
||||
else if (event.scrollDelta.dy < 0) { _onScrollPage(-1); }
|
||||
}
|
||||
},
|
||||
child: PdfView(
|
||||
controller: _ctrl,
|
||||
scrollDirection: Axis.vertical,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
backgroundDecoration: BoxDecoration(color: pt.bg0),
|
||||
onPageChanged: (p) => setState(() => _currentPage = p),
|
||||
// Render at high resolution in PNG: sharper text/line-art and no
|
||||
// JPEG chroma artifacts (matters most for AI/EPS/vector content),
|
||||
// and gives headroom for the InteractiveViewer zoom.
|
||||
renderer: (page) => page.render(
|
||||
width: page.width * 4,
|
||||
height: page.height * 4,
|
||||
format: PdfPageImageFormat.png,
|
||||
backgroundColor: '#ffffff',
|
||||
),
|
||||
builders: PdfViewBuilders<DefaultBuilderOptions>(
|
||||
options: const DefaultBuilderOptions(),
|
||||
documentLoaderBuilder: (ctx) => Center(
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent),
|
||||
),
|
||||
errorBuilder: (ctx, error) => _ErrorView(message: error.toString(), pt: pt),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Launch the immersive fullscreen page viewer.
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Material(
|
||||
color: pt.bg0.withAlpha(200),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(pt.rSm),
|
||||
side: BorderSide(color: pt.border),
|
||||
),
|
||||
child: IconButton(
|
||||
tooltip: 'Vollbild',
|
||||
icon: Icon(Icons.fullscreen_rounded, size: 22, color: pt.fg),
|
||||
onPressed: () => _PdfFullscreenViewer.show(
|
||||
context,
|
||||
bytes: widget.bytes,
|
||||
filename: widget.filename,
|
||||
initialPage: _currentPage,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_pageCount > 1) _ThumbnailStrip(
|
||||
pageCount: _pageCount,
|
||||
currentPage: _currentPage,
|
||||
thumbs: _thumbs,
|
||||
pt: pt,
|
||||
onTap: (p) {
|
||||
_ctrl.jumpToPage(p);
|
||||
setState(() => _currentPage = p);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
class _PdfFullscreenViewer extends StatefulWidget {
|
||||
final Uint8List bytes;
|
||||
final String filename;
|
||||
final int initialPage;
|
||||
const _PdfFullscreenViewer({required this.bytes, required this.filename, required this.initialPage});
|
||||
|
||||
static Future<void> show(BuildContext context, {
|
||||
required Uint8List bytes,
|
||||
required String filename,
|
||||
required int initialPage,
|
||||
}) {
|
||||
return Navigator.of(context, rootNavigator: true).push(PageRouteBuilder(
|
||||
opaque: true,
|
||||
barrierColor: Colors.black,
|
||||
transitionDuration: const Duration(milliseconds: 180),
|
||||
pageBuilder: (_, _, _) =>
|
||||
_PdfFullscreenViewer(bytes: bytes, filename: filename, initialPage: initialPage),
|
||||
transitionsBuilder: (_, anim, _, child) => FadeTransition(opacity: anim, child: child),
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
State<_PdfFullscreenViewer> createState() => _PdfFullscreenViewerState();
|
||||
}
|
||||
class _PdfFullscreenViewerState extends State<_PdfFullscreenViewer> {
|
||||
PdfDocument? _doc;
|
||||
final _tc = TransformationController();
|
||||
final _viewerKey = GlobalKey();
|
||||
int _page = 1;
|
||||
int _count = 0;
|
||||
Uint8List? _img;
|
||||
bool _loading = true;
|
||||
bool _rendering = false;
|
||||
bool _uiVisible = true;
|
||||
|
||||
bool get _isMobile => Platform.isAndroid || Platform.isIOS;
|
||||
|
||||
static const _minScale = 1.0;
|
||||
static const _maxScale = 8.0;
|
||||
static const _renderScale = 5.0; // high-res page render for crisp zoom
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_page = widget.initialPage;
|
||||
_open();
|
||||
}
|
||||
|
||||
Future<void> _open() async {
|
||||
try {
|
||||
final doc = await PdfDocument.openData(widget.bytes);
|
||||
if (!mounted) { await doc.close(); return; }
|
||||
_doc = doc;
|
||||
_count = doc.pagesCount;
|
||||
await _render(_page);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _render(int n) async {
|
||||
final doc = _doc;
|
||||
if (doc == null || _rendering) return;
|
||||
_rendering = true;
|
||||
if (mounted) setState(() => _loading = true);
|
||||
try {
|
||||
final page = await doc.getPage(n);
|
||||
final rw = (page.width * _renderScale).round();
|
||||
final rh = (page.height * _renderScale).round();
|
||||
var bytes = _RenderCache.get(widget.filename, widget.bytes.length, n, rw, rh);
|
||||
if (bytes == null) {
|
||||
final img = await page.render(
|
||||
width: page.width * _renderScale,
|
||||
height: page.height * _renderScale,
|
||||
format: PdfPageImageFormat.png,
|
||||
backgroundColor: '#ffffff',
|
||||
);
|
||||
bytes = img?.bytes;
|
||||
if (bytes != null) {
|
||||
_RenderCache.put(widget.filename, widget.bytes.length, n, rw, rh, bytes);
|
||||
}
|
||||
}
|
||||
await page.close();
|
||||
if (mounted) setState(() { _img = bytes; _loading = false; });
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
} finally {
|
||||
_rendering = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _goto(int n) {
|
||||
final t = n.clamp(1, _count);
|
||||
if (t == _page) return;
|
||||
_tc.value = Matrix4.identity(); // reset zoom on page change
|
||||
setState(() => _page = t);
|
||||
_render(t);
|
||||
}
|
||||
|
||||
// factor 1.2 = +20%, 1/1.2 = −20%
|
||||
void _zoom(double factor) {
|
||||
final size = _viewerKey.currentContext?.size;
|
||||
if (size == null) return;
|
||||
final current = _tc.value.getMaxScaleOnAxis();
|
||||
final target = (current * factor).clamp(_minScale, _maxScale);
|
||||
final applied = target / current;
|
||||
if ((applied - 1).abs() < 0.001) return;
|
||||
final cx = size.width / 2, cy = size.height / 2;
|
||||
final m = _tc.value.clone()
|
||||
..translateByDouble(cx, cy, 0, 1)
|
||||
..scaleByDouble(applied, applied, applied, 1)
|
||||
..translateByDouble(-cx, -cy, 0, 1);
|
||||
setState(() => _tc.value = m);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_doc?.close();
|
||||
_tc.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
// Single tap on the page (not on a control) toggles the UI.
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () => setState(() => _uiVisible = !_uiVisible),
|
||||
child: Listener(
|
||||
onPointerSignal: (e) {
|
||||
if (e is! PointerScrollEvent) return;
|
||||
final ctrl = HardwareKeyboard.instance.isControlPressed ||
|
||||
HardwareKeyboard.instance.isMetaPressed;
|
||||
if (ctrl) {
|
||||
_zoom(e.scrollDelta.dy < 0 ? 1.2 : 1 / 1.2);
|
||||
} else {
|
||||
_goto(e.scrollDelta.dy > 0 ? _page + 1 : _page - 1);
|
||||
}
|
||||
},
|
||||
child: _img == null
|
||||
? Center(
|
||||
child: _loading
|
||||
? CircularProgressIndicator(color: pt.accent)
|
||||
: const Icon(Icons.error_outline_rounded, color: Colors.white54, size: 40),
|
||||
)
|
||||
: InteractiveViewer(
|
||||
key: _viewerKey,
|
||||
transformationController: _tc,
|
||||
minScale: _minScale,
|
||||
maxScale: _maxScale,
|
||||
// Pinch-zoom on mobile only. On desktop the built-in
|
||||
// scroll-wheel zoom is disabled so plain scrolling
|
||||
// pages instead; zoom is Ctrl+wheel and the +/− buttons.
|
||||
scaleEnabled: _isMobile,
|
||||
child: Center(
|
||||
child: Image.memory(_img!, fit: BoxFit.contain, gaplessPlayback: true),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Right-side control column
|
||||
if (_uiVisible)
|
||||
Positioned(
|
||||
right: 10,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_FsBtn(icon: Icons.add_rounded, tooltip: 'Vergrößern', pt: pt, onTap: () => _zoom(1.2)),
|
||||
const SizedBox(height: 8),
|
||||
_FsBtn(icon: Icons.remove_rounded, tooltip: 'Verkleinern', pt: pt, onTap: () => _zoom(1 / 1.2)),
|
||||
const SizedBox(height: 8),
|
||||
_FsBtn(icon: Icons.download_rounded, tooltip: 'Herunterladen', pt: pt,
|
||||
onTap: () => _saveBytes(context, widget.bytes, widget.filename)),
|
||||
const SizedBox(height: 8),
|
||||
_FsBtn(icon: Icons.fullscreen_exit_rounded, tooltip: 'Vollbild beenden', pt: pt,
|
||||
onTap: () => Navigator.of(context).maybePop()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom-center page navigation
|
||||
if (_uiVisible && _count > 1)
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withAlpha(160),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: Colors.white24),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_FsBtn(icon: Icons.chevron_left_rounded, tooltip: 'Vorherige', pt: pt,
|
||||
enabled: _page > 1, onTap: () => _goto(_page - 1), flat: true),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Text('$_page / $_count',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13,
|
||||
fontFeatures: [FontFeature.tabularFigures()])),
|
||||
),
|
||||
_FsBtn(icon: Icons.chevron_right_rounded, tooltip: 'Nächste', pt: pt,
|
||||
enabled: _page < _count, onTap: () => _goto(_page + 1), flat: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
class _FsBtn extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback onTap;
|
||||
final bool enabled;
|
||||
final bool flat;
|
||||
const _FsBtn({required this.icon, required this.tooltip, required this.pt,
|
||||
required this.onTap, this.enabled = true, this.flat = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final btn = Material(
|
||||
color: flat ? Colors.transparent : Colors.black.withAlpha(160),
|
||||
shape: flat
|
||||
? const CircleBorder()
|
||||
: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10), side: const BorderSide(color: Colors.white24)),
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
icon: Icon(icon, color: enabled ? Colors.white : Colors.white30, size: 22),
|
||||
onPressed: enabled ? onTap : null,
|
||||
),
|
||||
);
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
class _ThumbnailStrip extends StatefulWidget {
|
||||
final int pageCount;
|
||||
final int currentPage;
|
||||
final Map<int, Uint8List?> thumbs;
|
||||
final PyramidTheme pt;
|
||||
final ValueChanged<int> onTap;
|
||||
const _ThumbnailStrip({required this.pageCount, required this.currentPage, required this.thumbs, required this.pt, required this.onTap});
|
||||
|
||||
@override
|
||||
State<_ThumbnailStrip> createState() => _ThumbnailStripState();
|
||||
}
|
||||
class _ThumbnailStripState extends State<_ThumbnailStrip> {
|
||||
late final ScrollController _sc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_sc = ScrollController();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_ThumbnailStrip old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.currentPage != widget.currentPage) {
|
||||
_scrollToPage(widget.currentPage);
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToPage(int page) {
|
||||
const thumbW = 60.0 + 8.0; // thumb + margin
|
||||
final target = (page - 1) * thumbW - 80;
|
||||
if (_sc.hasClients) {
|
||||
_sc.animateTo(
|
||||
target.clamp(0.0, _sc.position.maxScrollExtent),
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sc.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
return Container(
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg1,
|
||||
border: Border(top: BorderSide(color: pt.border)),
|
||||
),
|
||||
child: ListView.builder(
|
||||
controller: _sc,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
itemCount: widget.pageCount,
|
||||
itemBuilder: (ctx, i) {
|
||||
final page = i + 1;
|
||||
final isCurrent = page == widget.currentPage;
|
||||
final bytes = widget.thumbs[page];
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onTap(page),
|
||||
child: Container(
|
||||
width: 52,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: isCurrent ? pt.accent : pt.border,
|
||||
width: isCurrent ? 2 : 1,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: bytes != null
|
||||
? Image.memory(bytes, fit: BoxFit.cover)
|
||||
: Center(
|
||||
child: Text('$page', style: TextStyle(color: pt.fgDim, fontSize: 10)),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
part of '../document_viewer.dart';
|
||||
|
||||
class _SvgViewer extends StatelessWidget {
|
||||
final Uint8List bytes;
|
||||
final PyramidTheme pt;
|
||||
const _SvgViewer({required this.bytes, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: InteractiveViewer(
|
||||
minScale: 0.5,
|
||||
maxScale: 10,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: SvgPicture.memory(bytes),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
class _MarkdownViewer extends StatelessWidget {
|
||||
final Uint8List bytes;
|
||||
final PyramidTheme pt;
|
||||
const _MarkdownViewer({required this.bytes, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final String text;
|
||||
try {
|
||||
text = utf8.decode(bytes, allowMalformed: true);
|
||||
} catch (e) {
|
||||
return _ErrorView(message: e.toString(), pt: pt);
|
||||
}
|
||||
|
||||
return Markdown(
|
||||
data: text,
|
||||
selectable: true,
|
||||
padding: const EdgeInsets.all(24),
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: TextStyle(color: pt.fg, fontSize: 14, height: 1.65),
|
||||
h1: TextStyle(color: pt.fg, fontSize: 22, fontWeight: FontWeight.w700),
|
||||
h2: TextStyle(color: pt.fg, fontSize: 18, fontWeight: FontWeight.w600),
|
||||
h3: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w600),
|
||||
h4: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600),
|
||||
code: TextStyle(color: pt.accent, fontFamily: 'monospace', fontSize: 13, backgroundColor: pt.bg2),
|
||||
codeblockDecoration: BoxDecoration(
|
||||
color: pt.bg2,
|
||||
borderRadius: BorderRadius.circular(pt.rSm),
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
blockquoteDecoration: BoxDecoration(
|
||||
border: Border(left: BorderSide(color: pt.accent, width: 3)),
|
||||
color: pt.accentSoft,
|
||||
),
|
||||
blockquotePadding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
|
||||
a: TextStyle(color: pt.accent, decoration: TextDecoration.underline, decorationColor: pt.accent),
|
||||
strong: TextStyle(color: pt.fg, fontWeight: FontWeight.w600),
|
||||
em: TextStyle(color: pt.fg, fontStyle: FontStyle.italic),
|
||||
listBullet: TextStyle(color: pt.fgMuted),
|
||||
horizontalRuleDecoration: BoxDecoration(border: Border(top: BorderSide(color: pt.border))),
|
||||
),
|
||||
onTapLink: (text, href, title) {
|
||||
if (href != null) launchUrl(Uri.parse(href), mode: LaunchMode.externalApplication);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
class _TextViewer extends StatelessWidget {
|
||||
final Uint8List bytes;
|
||||
final PyramidTheme pt;
|
||||
final bool mono;
|
||||
static const _maxBytes = 512 * 1024;
|
||||
const _TextViewer({required this.bytes, required this.pt, required this.mono});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool truncated = bytes.length > _maxBytes;
|
||||
final slice = truncated ? bytes.sublist(0, _maxBytes) : bytes;
|
||||
final String text;
|
||||
try {
|
||||
text = utf8.decode(slice, allowMalformed: true);
|
||||
} catch (e) {
|
||||
return _ErrorView(message: e.toString(), pt: pt);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (truncated)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
color: pt.bg2,
|
||||
child: Text('Zeige erste ${_maxBytes ~/ 1024} KB', style: TextStyle(color: pt.fgDim, fontSize: 11)),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: SelectableText(
|
||||
text,
|
||||
style: mono
|
||||
? TextStyle(color: pt.fg, fontFamily: 'monospace', fontSize: 13, height: 1.5)
|
||||
: TextStyle(color: pt.fg, fontSize: 14, height: 1.65),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
class _OfficeTextViewer extends StatefulWidget {
|
||||
final Uint8List bytes;
|
||||
final String filename;
|
||||
final PyramidTheme pt;
|
||||
const _OfficeTextViewer({required this.bytes, required this.filename, required this.pt});
|
||||
|
||||
@override
|
||||
State<_OfficeTextViewer> createState() => _OfficeTextViewerState();
|
||||
}
|
||||
class _OfficeTextViewerState extends State<_OfficeTextViewer> {
|
||||
String? _text;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_extract();
|
||||
}
|
||||
|
||||
void _extract() {
|
||||
try {
|
||||
final archive = ZipDecoder().decodeBytes(widget.bytes);
|
||||
final ext = widget.filename.contains('.') ? widget.filename.split('.').last.toLowerCase() : '';
|
||||
|
||||
final xmlFiles = <ArchiveFile>[];
|
||||
for (final f in archive.files) {
|
||||
if (!f.isFile) continue;
|
||||
final n = f.name.toLowerCase();
|
||||
if (ext == 'docx' && n == 'word/document.xml') { xmlFiles.add(f); break; }
|
||||
if (ext == 'xlsx' && n.startsWith('xl/worksheets/') && n.endsWith('.xml')) xmlFiles.add(f);
|
||||
if (ext == 'pptx' && n.startsWith('ppt/slides/slide') && n.endsWith('.xml')) xmlFiles.add(f);
|
||||
// ODF formats (Writer/Impress/Calc) all use content.xml
|
||||
if ((ext == 'odt' || ext == 'odp' || ext == 'ods') && n == 'content.xml') { xmlFiles.add(f); break; }
|
||||
}
|
||||
|
||||
final buf = StringBuffer();
|
||||
for (final f in xmlFiles) {
|
||||
final xml = utf8.decode(f.content as List<int>, allowMalformed: true);
|
||||
// Strip XML tags, collapse whitespace
|
||||
final stripped = xml
|
||||
.replaceAll(RegExp(r'<[^>]+>'), ' ')
|
||||
.replaceAll(RegExp(r'&'), '&')
|
||||
.replaceAll(RegExp(r'<'), '<')
|
||||
.replaceAll(RegExp(r'>'), '>')
|
||||
.replaceAll(RegExp(r' '), ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
buf.writeln(stripped);
|
||||
buf.writeln();
|
||||
}
|
||||
|
||||
_text = buf.toString().trim().isEmpty
|
||||
? '(Kein lesbarer Text gefunden)'
|
||||
: buf.toString().trim();
|
||||
} catch (e) {
|
||||
_error = e.toString().split('\n').first;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
if (_error != null) return _ErrorView(message: _error!, pt: pt);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: pt.bg2,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline_rounded, size: 14, color: pt.fgDim),
|
||||
const SizedBox(width: 8),
|
||||
Text('Textvorschau (Formatierung nicht verfügbar)', style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: SelectableText(
|
||||
_text ?? '',
|
||||
style: TextStyle(color: pt.fg, fontSize: 14, height: 1.65),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user