diff --git a/CHANGES.md b/CHANGES.md index cef0c81..85efe3e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 diff --git a/PROGRESS.md b/PROGRESS.md index 17d3dd6..84a6f59 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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` diff --git a/ROADMAP.md b/ROADMAP.md index d5f592a..f157cdb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) diff --git a/lib/features/chat/document/document_viewer_archive.dart b/lib/features/chat/document/document_viewer_archive.dart new file mode 100644 index 0000000..426e2e4 --- /dev/null +++ b/lib/features/chat/document/document_viewer_archive.dart @@ -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(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); + _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); + } +} diff --git a/lib/features/chat/document/document_viewer_core.dart b/lib/features/chat/document/document_viewer_core.dart new file mode 100644 index 0000000..4fbffa6 --- /dev/null +++ b/lib/features/chat/document/document_viewer_core.dart @@ -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 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, + ), + ], + ), + ); + } +} diff --git a/lib/features/chat/document/document_viewer_misc.dart b/lib/features/chat/document/document_viewer_misc.dart new file mode 100644 index 0000000..d97f2e5 --- /dev/null +++ b/lib/features/chat/document/document_viewer_misc.dart @@ -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), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/chat/document/document_viewer_pdf.dart b/lib/features/chat/document/document_viewer_pdf.dart new file mode 100644 index 0000000..b943bfb --- /dev/null +++ b/lib/features/chat/document/document_viewer_pdf.dart @@ -0,0 +1,803 @@ +part of '../document_viewer.dart'; + +class _RenderCache { + static const _max = 80; + static final _data = {}; + + 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 _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 createState() => _PdfInlinePreviewState(); +} +class _PdfInlinePreviewState extends State { + PdfDocument? _doc; + int _currentPage = 1; + int _pageCount = 0; + double? _pageAspect; // width/height + final Map _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 _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 _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 _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 _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 _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( + 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 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 _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 _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 thumbs; + final PyramidTheme pt; + final ValueChanged 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)), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/features/chat/document/document_viewer_text.dart b/lib/features/chat/document/document_viewer_text.dart new file mode 100644 index 0000000..545d261 --- /dev/null +++ b/lib/features/chat/document/document_viewer_text.dart @@ -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 = []; + 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, 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), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/chat/document_viewer.dart b/lib/features/chat/document_viewer.dart index d647782..98b9d70 100644 --- a/lib/features/chat/document_viewer.dart +++ b/lib/features/chat/document_viewer.dart @@ -13,1761 +13,8 @@ import 'package:pdfx/pdfx.dart'; import 'package:pyramid/core/theme.dart'; import 'package:url_launcher/url_launcher.dart'; -// ─── Static render cache ────────────────────────────────────────────────────── -// Survives widget dispose/recreate (e.g. scroll in chat list). -// Key: filename + file-size-bytes + page + render-dimensions. -// Evicts the oldest entry (FIFO) when the limit is reached. - -class _RenderCache { - static const _max = 80; - static final _data = {}; - - 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; - } -} - -// ─── Inline chat preview ────────────────────────────────────────────────────── -// Used by _DocPreviewContent in message_group.dart. -// Shows the first page of a PDF as an image with page navigation arrows. -// Tap → opens the full DocumentViewer overlay. - -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 createState() => _PdfInlinePreviewState(); -} - -class _PdfInlinePreviewState extends State { - PdfDocument? _doc; - int _currentPage = 1; - int _pageCount = 0; - double? _pageAspect; // width/height - final Map _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 _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 _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 _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, - ), - ], - ), - ); - } -} - -// ─── Full overlay viewer ────────────────────────────────────────────────────── - -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 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, - ), - ), - ], - ), - ), - ), - ); - } -} - -// ─── Header ────────────────────────────────────────────────────────────────── - -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)), - ), - ); - } -} - -// ─── Content dispatcher ─────────────────────────────────────────────────────── - -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), - }; - } -} - -// ─── PDF full viewer with thumbnail strip ──────────────────────────────────── - -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 _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 _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 _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( - 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); - }, - ), - ], - ); - } -} - -// ─── Immersive fullscreen PDF viewer ───────────────────────────────────────── -// Launched from the enlarged preview. Per-page high-res render, pan via drag, -// zoom via +/− buttons (20%/click) and Ctrl+wheel, page nav via wheel/arrows. - -Future _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 _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 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 _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 _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 thumbs; - final PyramidTheme pt; - final ValueChanged 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)), - ), - ), - ); - }, - ), - ); - } -} - -// ─── SVG viewer ─────────────────────────────────────────────────────────────── - -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), - ), - ), - ); - } -} - -// ─── Markdown viewer ────────────────────────────────────────────────────────── - -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); - }, - ); - } -} - -// ─── Text / Code viewer ─────────────────────────────────────────────────────── - -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), - ), - ), - ), - ], - ); - } -} - -// ─── Office text extraction (DOCX / PPTX / XLSX) ──────────────────────────── - -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 = []; - 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, 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), - ), - ), - ), - ], - ); - } -} - -// ─── Archive viewer ─────────────────────────────────────────────────────────── - -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(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}); -} - -// ─── PSD/PSB thumbnail viewer ──────────────────────────────────────────────── -// Extracts the JPEG thumbnail stored in PSD Image Resource 1036. - -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), - ), - ), - ), - ], - ); - } -} - -// ─── ZIP-preview viewer (Sketch, XD) ───────────────────────────────────────── -// These formats are ZIP archives that embed a preview PNG. - -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); - _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); - } -} - -// ─── Proprietary format info card ──────────────────────────────────────────── - -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), - ), - ], - ), - ), - ], - ), - ); - } -} - -// ─── Unsupported / Error ───────────────────────────────────────────────────── - -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 _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), - ], - ), - ), - ); -} - -// ─── Type detection ─────────────────────────────────────────────────────────── - -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, - }; -} +part 'document/document_viewer_core.dart'; +part 'document/document_viewer_pdf.dart'; +part 'document/document_viewer_text.dart'; +part 'document/document_viewer_archive.dart'; +part 'document/document_viewer_misc.dart';