Files
pyramid/lib/features/chat/document_viewer.dart
T
Bernd Steckmeister 99f10c5210 style: doppelte Unterstriche in Closure-Parametern bereinigen
Dart 3 erlaubt mehrere Wildcard-Parameter mit demselben Namen "_" in
einem Scope, daher sind __ / ___ für ungenutzte Callback-Parameter
(pageBuilder, errorBuilder etc.) unnötig. Rein mechanische Änderung,
keine Verhaltensänderung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 06:30:40 +02:00

1774 lines
62 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:path_provider/path_provider.dart';
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 = <String, Uint8List>{};
static String _k(String name, int sz, int page, int w, int h) =>
'$name\x00$sz\x00$page\x00${w}x$h';
static Uint8List? get(String name, int sz, int page, int w, int h) =>
_data[_k(name, sz, page, w, h)];
static void put(String name, int sz, int page, int w, int h, Uint8List img) {
if (_data.length >= _max) _data.remove(_data.keys.first);
_data[_k(name, sz, page, w, h)] = img;
}
}
// ─── 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<PdfInlinePreview> createState() => _PdfInlinePreviewState();
}
class _PdfInlinePreviewState extends State<PdfInlinePreview> {
PdfDocument? _doc;
int _currentPage = 1;
int _pageCount = 0;
double? _pageAspect; // width/height
final Map<int, Uint8List?> _cache = {};
bool _loading = true;
String? _error;
bool _rendering = false;
static const _previewW = 280.0;
static const _renderScale = 2.0;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
try {
final doc = await PdfDocument.openData(widget.bytes);
if (!mounted) { await doc.close(); return; }
_doc = doc;
_pageCount = doc.pagesCount;
setState(() => _loading = false);
await _ensurePage(1);
} catch (e) {
if (mounted) setState(() { _loading = false; _error = e.toString().split('\n').first; });
}
}
Future<void> _ensurePage(int n) async {
if (_cache.containsKey(n) || _rendering) return;
_rendering = true;
try {
final page = await _doc!.getPage(n);
if (mounted && _pageAspect == null) {
setState(() => _pageAspect = page.width / page.height);
}
final rw = (page.width * _renderScale).round();
final rh = (page.height * _renderScale).round();
final hit = _RenderCache.get(widget.filename, widget.bytes.length, n, rw, rh);
if (hit != null) {
await page.close();
if (mounted) setState(() => _cache[n] = hit);
return;
}
final img = await page.render(
width: page.width * _renderScale,
height: page.height * _renderScale,
format: PdfPageImageFormat.jpeg,
backgroundColor: '#ffffff',
);
await page.close();
if (img?.bytes != null) {
_RenderCache.put(widget.filename, widget.bytes.length, n, rw, rh, img!.bytes);
}
if (mounted) setState(() => _cache[n] = img?.bytes);
} catch (_) {
_cache[n] = null;
} finally {
_rendering = false;
}
}
void _goTo(int page) {
if (page < 1 || page > _pageCount) return;
setState(() => _currentPage = page);
_ensurePage(page);
_ensurePage((page + 1).clamp(1, _pageCount));
}
void _openFull() {
DocumentViewer.show(
context,
filename: widget.filename,
bytes: widget.bytes,
senderName: widget.senderName,
initialPage: _currentPage,
);
}
@override
void dispose() {
_doc?.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
final radius = BorderRadius.circular(pt.rBase);
if (_loading) {
return Container(
width: _previewW,
// Match the typical loaded A4 height so the preview doesn't jump (and
// shift the scroll position) once the first page finishes rendering.
height: (_previewW / 0.707).clamp(160.0, 400.0),
margin: const EdgeInsets.only(top: 4, bottom: 4),
decoration: BoxDecoration(
color: pt.bg2,
borderRadius: radius,
border: Border.all(color: pt.border),
),
child: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)),
);
}
if (_error != null) {
return _FileFallback(filename: widget.filename, pt: pt, onDownload: widget.onDownload);
}
final pageBytes = _cache[_currentPage];
final aspect = _pageAspect ?? 0.707; // A4 fallback
final imgH = (_previewW / aspect).clamp(160.0, 400.0);
return Listener(
onPointerSignal: (event) {
if (event is PointerScrollEvent) {
GestureBinding.instance.pointerSignalResolver.register(event, (resolved) {
final dy = (resolved as PointerScrollEvent).scrollDelta.dy;
if (dy > 0) { _goTo(_currentPage + 1); }
else if (dy < 0) { _goTo(_currentPage - 1); }
});
}
},
child: GestureDetector(
onTap: _openFull,
onHorizontalDragEnd: (d) {
if ((d.primaryVelocity ?? 0) < -200) _goTo(_currentPage + 1);
if ((d.primaryVelocity ?? 0) > 200) _goTo(_currentPage - 1);
},
child: Container(
margin: const EdgeInsets.only(top: 4, bottom: 4),
width: _previewW,
decoration: BoxDecoration(
borderRadius: radius,
border: Border.all(color: pt.border),
),
clipBehavior: Clip.hardEdge,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Page image
Container(
width: _previewW,
height: imgH,
color: Colors.white,
child: pageBytes != null
? Image.memory(pageBytes, width: _previewW, height: imgH, fit: BoxFit.cover, alignment: Alignment.topCenter)
: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)),
),
// Footer
Container(
color: pt.bg2,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
child: Row(
children: [
Icon(Icons.picture_as_pdf_outlined, size: 14, color: pt.fgDim),
const SizedBox(width: 6),
Expanded(
child: Text(
widget.filename,
style: TextStyle(color: pt.fg, fontSize: 12, fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
if (_pageCount > 1) ...[
_NavArrow(
icon: Icons.chevron_left_rounded,
enabled: _currentPage > 1,
pt: pt,
onTap: () => _goTo(_currentPage - 1),
),
Text(
'$_currentPage/$_pageCount',
style: TextStyle(color: pt.fgMuted, fontSize: 11, fontFeatures: const [FontFeature.tabularFigures()]),
),
_NavArrow(
icon: Icons.chevron_right_rounded,
enabled: _currentPage < _pageCount,
pt: pt,
onTap: () => _goTo(_currentPage + 1),
),
],
if (widget.onDownload != null) ...[
const SizedBox(width: 4),
GestureDetector(
onTap: widget.onDownload,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(Icons.download_rounded, size: 16, color: pt.fgMuted),
),
),
],
],
),
),
],
),
),
),
);
}
}
class _NavArrow extends StatelessWidget {
final IconData icon;
final bool enabled;
final PyramidTheme pt;
final VoidCallback onTap;
const _NavArrow({required this.icon, required this.enabled, required this.pt, required this.onTap});
@override
Widget build(BuildContext context) => GestureDetector(
onTap: enabled ? onTap : null,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Icon(icon, size: 18, color: enabled ? pt.fg : pt.fgDim),
),
);
}
class _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<void> show(
BuildContext context, {
required String filename,
required Uint8List bytes,
required String senderName,
int initialPage = 1,
}) {
return showGeneralDialog(
context: context,
barrierDismissible: true,
barrierLabel: 'Schließen',
barrierColor: Colors.black.withAlpha(178),
transitionDuration: const Duration(milliseconds: 180),
transitionBuilder: (ctx, anim, a, child) =>
FadeTransition(opacity: anim, child: child),
pageBuilder: (ctx, a, b) => DocumentViewer(
filename: filename,
bytes: bytes,
senderName: senderName,
initialPage: initialPage,
),
);
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final type = _detectType(filename);
final size = MediaQuery.sizeOf(context);
final maxW = (size.width - 48).clamp(300.0, 1100.0);
final maxH = (size.height - 48).clamp(300.0, 900.0);
return Material(
type: MaterialType.transparency,
child: Center(
child: Container(
width: maxW,
height: maxH,
decoration: BoxDecoration(
color: pt.bg0,
borderRadius: BorderRadius.circular(pt.rLg),
border: Border.all(color: pt.border),
boxShadow: [BoxShadow(color: Colors.black.withAlpha(80), blurRadius: 32, offset: const Offset(0, 8))],
),
clipBehavior: Clip.hardEdge,
child: Column(
children: [
_DocHeader(filename: filename, senderName: senderName, pt: pt),
Expanded(
child: _DocContent(
filename: filename,
bytes: bytes,
type: type,
pt: pt,
initialPage: initialPage,
),
),
],
),
),
),
);
}
}
// ─── 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<int, Uint8List?> _thumbs = {};
int _pageCount = 0;
int _currentPage = 1;
bool _animating = false;
@override
void initState() {
super.initState();
_currentPage = widget.initialPage;
_ctrl = PdfController(
document: PdfDocument.openData(widget.bytes),
initialPage: widget.initialPage,
);
_ctrl.pageListenable.addListener(_onPage);
_initThumbs();
}
Future<void> _initThumbs() async {
final doc = await PdfDocument.openData(widget.bytes);
if (!mounted) { await doc.close(); return; }
_thumbDoc = doc;
if (!mounted) { await doc.close(); _thumbDoc = null; return; }
setState(() => _pageCount = doc.pagesCount);
for (var i = 1; i <= doc.pagesCount; i++) {
_renderThumb(i);
}
}
Future<void> _renderThumb(int n) async {
final doc = _thumbDoc;
if (doc == null) return;
try {
final page = await doc.getPage(n);
final rw = (page.width * 0.12).round();
final rh = (page.height * 0.12).round();
final hit = _RenderCache.get(widget.filename, widget.bytes.length, n, rw, rh);
if (hit != null) {
await page.close();
if (mounted) setState(() => _thumbs[n] = hit);
return;
}
final img = await page.render(
width: page.width * 0.12,
height: page.height * 0.12,
format: PdfPageImageFormat.jpeg,
backgroundColor: '#ffffff',
);
await page.close();
if (img?.bytes != null) {
_RenderCache.put(widget.filename, widget.bytes.length, n, rw, rh, img!.bytes);
}
if (mounted) setState(() => _thumbs[n] = img?.bytes);
} catch (_) {
_thumbs[n] = null;
}
}
void _onPage() => setState(() => _currentPage = _ctrl.page);
void _onScrollPage(int delta) {
if (_animating) return;
final pageCount = _ctrl.pagesCount ?? _pageCount;
if (pageCount == 0) return;
final target = (_currentPage + delta).clamp(1, pageCount);
if (target == _currentPage) return;
_animating = true;
_ctrl.animateToPage(
target,
duration: const Duration(milliseconds: 280),
curve: Curves.easeInOut,
).then((_) { if (mounted) setState(() => _animating = false); });
}
@override
void dispose() {
_ctrl.pageListenable.removeListener(_onPage);
_ctrl.dispose();
_thumbDoc?.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return Column(
children: [
Expanded(
child: Stack(
children: [
Listener(
onPointerSignal: (event) {
if (event is PointerScrollEvent) {
if (event.scrollDelta.dy > 0) { _onScrollPage(1); }
else if (event.scrollDelta.dy < 0) { _onScrollPage(-1); }
}
},
child: PdfView(
controller: _ctrl,
scrollDirection: Axis.vertical,
physics: const NeverScrollableScrollPhysics(),
backgroundDecoration: BoxDecoration(color: pt.bg0),
onPageChanged: (p) => setState(() => _currentPage = p),
// Render at high resolution in PNG: sharper text/line-art and no
// JPEG chroma artifacts (matters most for AI/EPS/vector content),
// and gives headroom for the InteractiveViewer zoom.
renderer: (page) => page.render(
width: page.width * 4,
height: page.height * 4,
format: PdfPageImageFormat.png,
backgroundColor: '#ffffff',
),
builders: PdfViewBuilders<DefaultBuilderOptions>(
options: const DefaultBuilderOptions(),
documentLoaderBuilder: (ctx) => Center(
child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent),
),
errorBuilder: (ctx, error) => _ErrorView(message: error.toString(), pt: pt),
),
),
),
// Launch the immersive fullscreen page viewer.
Positioned(
top: 8,
right: 8,
child: Material(
color: pt.bg0.withAlpha(200),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(pt.rSm),
side: BorderSide(color: pt.border),
),
child: IconButton(
tooltip: 'Vollbild',
icon: Icon(Icons.fullscreen_rounded, size: 22, color: pt.fg),
onPressed: () => _PdfFullscreenViewer.show(
context,
bytes: widget.bytes,
filename: widget.filename,
initialPage: _currentPage,
),
),
),
),
],
),
),
if (_pageCount > 1) _ThumbnailStrip(
pageCount: _pageCount,
currentPage: _currentPage,
thumbs: _thumbs,
pt: pt,
onTap: (p) {
_ctrl.jumpToPage(p);
setState(() => _currentPage = p);
},
),
],
);
}
}
// ─── 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<void> _saveBytes(BuildContext context, Uint8List bytes, String filename) async {
try {
Directory? dir;
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
dir = await getDownloadsDirectory();
}
dir ??= await getTemporaryDirectory();
final ext = filename.contains('.') ? filename.split('.').last : 'bin';
final name = 'pyramid_${DateTime.now().millisecondsSinceEpoch}.$ext';
final file = File('${dir.path}/$name');
await file.writeAsBytes(bytes);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gespeichert: ${file.path}'), duration: const Duration(seconds: 4)),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler: $e')));
}
}
}
class _PdfFullscreenViewer extends StatefulWidget {
final Uint8List bytes;
final String filename;
final int initialPage;
const _PdfFullscreenViewer({required this.bytes, required this.filename, required this.initialPage});
static Future<void> show(BuildContext context, {
required Uint8List bytes,
required String filename,
required int initialPage,
}) {
return Navigator.of(context, rootNavigator: true).push(PageRouteBuilder(
opaque: true,
barrierColor: Colors.black,
transitionDuration: const Duration(milliseconds: 180),
pageBuilder: (_, _, _) =>
_PdfFullscreenViewer(bytes: bytes, filename: filename, initialPage: initialPage),
transitionsBuilder: (_, anim, _, child) => FadeTransition(opacity: anim, child: child),
));
}
@override
State<_PdfFullscreenViewer> createState() => _PdfFullscreenViewerState();
}
class _PdfFullscreenViewerState extends State<_PdfFullscreenViewer> {
PdfDocument? _doc;
final _tc = TransformationController();
final _viewerKey = GlobalKey();
int _page = 1;
int _count = 0;
Uint8List? _img;
bool _loading = true;
bool _rendering = false;
bool _uiVisible = true;
bool get _isMobile => Platform.isAndroid || Platform.isIOS;
static const _minScale = 1.0;
static const _maxScale = 8.0;
static const _renderScale = 5.0; // high-res page render for crisp zoom
@override
void initState() {
super.initState();
_page = widget.initialPage;
_open();
}
Future<void> _open() async {
try {
final doc = await PdfDocument.openData(widget.bytes);
if (!mounted) { await doc.close(); return; }
_doc = doc;
_count = doc.pagesCount;
await _render(_page);
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _render(int n) async {
final doc = _doc;
if (doc == null || _rendering) return;
_rendering = true;
if (mounted) setState(() => _loading = true);
try {
final page = await doc.getPage(n);
final rw = (page.width * _renderScale).round();
final rh = (page.height * _renderScale).round();
var bytes = _RenderCache.get(widget.filename, widget.bytes.length, n, rw, rh);
if (bytes == null) {
final img = await page.render(
width: page.width * _renderScale,
height: page.height * _renderScale,
format: PdfPageImageFormat.png,
backgroundColor: '#ffffff',
);
bytes = img?.bytes;
if (bytes != null) {
_RenderCache.put(widget.filename, widget.bytes.length, n, rw, rh, bytes);
}
}
await page.close();
if (mounted) setState(() { _img = bytes; _loading = false; });
} catch (_) {
if (mounted) setState(() => _loading = false);
} finally {
_rendering = false;
}
}
void _goto(int n) {
final t = n.clamp(1, _count);
if (t == _page) return;
_tc.value = Matrix4.identity(); // reset zoom on page change
setState(() => _page = t);
_render(t);
}
// factor 1.2 = +20%, 1/1.2 = 20%
void _zoom(double factor) {
final size = _viewerKey.currentContext?.size;
if (size == null) return;
final current = _tc.value.getMaxScaleOnAxis();
final target = (current * factor).clamp(_minScale, _maxScale);
final applied = target / current;
if ((applied - 1).abs() < 0.001) return;
final cx = size.width / 2, cy = size.height / 2;
final m = _tc.value.clone()
..translate(cx, cy)
..scale(applied)
..translate(-cx, -cy);
setState(() => _tc.value = m);
}
@override
void dispose() {
_doc?.close();
_tc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
// Single tap on the page (not on a control) toggles the UI.
behavior: HitTestBehavior.translucent,
onTap: () => setState(() => _uiVisible = !_uiVisible),
child: Listener(
onPointerSignal: (e) {
if (e is! PointerScrollEvent) return;
final ctrl = HardwareKeyboard.instance.isControlPressed ||
HardwareKeyboard.instance.isMetaPressed;
if (ctrl) {
_zoom(e.scrollDelta.dy < 0 ? 1.2 : 1 / 1.2);
} else {
_goto(e.scrollDelta.dy > 0 ? _page + 1 : _page - 1);
}
},
child: _img == null
? Center(
child: _loading
? CircularProgressIndicator(color: pt.accent)
: const Icon(Icons.error_outline_rounded, color: Colors.white54, size: 40),
)
: InteractiveViewer(
key: _viewerKey,
transformationController: _tc,
minScale: _minScale,
maxScale: _maxScale,
// Pinch-zoom on mobile only. On desktop the built-in
// scroll-wheel zoom is disabled so plain scrolling
// pages instead; zoom is Ctrl+wheel and the +/ buttons.
scaleEnabled: _isMobile,
child: Center(
child: Image.memory(_img!, fit: BoxFit.contain, gaplessPlayback: true),
),
),
),
),
),
// Right-side control column
if (_uiVisible)
Positioned(
right: 10,
top: 0,
bottom: 0,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_FsBtn(icon: Icons.add_rounded, tooltip: 'Vergrößern', pt: pt, onTap: () => _zoom(1.2)),
const SizedBox(height: 8),
_FsBtn(icon: Icons.remove_rounded, tooltip: 'Verkleinern', pt: pt, onTap: () => _zoom(1 / 1.2)),
const SizedBox(height: 8),
_FsBtn(icon: Icons.download_rounded, tooltip: 'Herunterladen', pt: pt,
onTap: () => _saveBytes(context, widget.bytes, widget.filename)),
const SizedBox(height: 8),
_FsBtn(icon: Icons.fullscreen_exit_rounded, tooltip: 'Vollbild beenden', pt: pt,
onTap: () => Navigator.of(context).maybePop()),
],
),
),
),
// Bottom-center page navigation
if (_uiVisible && _count > 1)
Positioned(
bottom: 16,
left: 0,
right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withAlpha(160),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Colors.white24),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_FsBtn(icon: Icons.chevron_left_rounded, tooltip: 'Vorherige', pt: pt,
enabled: _page > 1, onTap: () => _goto(_page - 1), flat: true),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text('$_page / $_count',
style: const TextStyle(color: Colors.white, fontSize: 13,
fontFeatures: [FontFeature.tabularFigures()])),
),
_FsBtn(icon: Icons.chevron_right_rounded, tooltip: 'Nächste', pt: pt,
enabled: _page < _count, onTap: () => _goto(_page + 1), flat: true),
],
),
),
),
),
],
),
),
);
}
}
class _FsBtn extends StatelessWidget {
final IconData icon;
final String tooltip;
final PyramidTheme pt;
final VoidCallback onTap;
final bool enabled;
final bool flat;
const _FsBtn({required this.icon, required this.tooltip, required this.pt,
required this.onTap, this.enabled = true, this.flat = false});
@override
Widget build(BuildContext context) {
final btn = Material(
color: flat ? Colors.transparent : Colors.black.withAlpha(160),
shape: flat
? const CircleBorder()
: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10), side: const BorderSide(color: Colors.white24)),
child: IconButton(
tooltip: tooltip,
icon: Icon(icon, color: enabled ? Colors.white : Colors.white30, size: 22),
onPressed: enabled ? onTap : null,
),
);
return btn;
}
}
class _ThumbnailStrip extends StatefulWidget {
final int pageCount;
final int currentPage;
final Map<int, Uint8List?> thumbs;
final PyramidTheme pt;
final ValueChanged<int> onTap;
const _ThumbnailStrip({required this.pageCount, required this.currentPage, required this.thumbs, required this.pt, required this.onTap});
@override
State<_ThumbnailStrip> createState() => _ThumbnailStripState();
}
class _ThumbnailStripState extends State<_ThumbnailStrip> {
late final ScrollController _sc;
@override
void initState() {
super.initState();
_sc = ScrollController();
}
@override
void didUpdateWidget(_ThumbnailStrip old) {
super.didUpdateWidget(old);
if (old.currentPage != widget.currentPage) {
_scrollToPage(widget.currentPage);
}
}
void _scrollToPage(int page) {
const thumbW = 60.0 + 8.0; // thumb + margin
final target = (page - 1) * thumbW - 80;
if (_sc.hasClients) {
_sc.animateTo(
target.clamp(0.0, _sc.position.maxScrollExtent),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
}
@override
void dispose() {
_sc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
return Container(
height: 88,
decoration: BoxDecoration(
color: pt.bg1,
border: Border(top: BorderSide(color: pt.border)),
),
child: ListView.builder(
controller: _sc,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
itemCount: widget.pageCount,
itemBuilder: (ctx, i) {
final page = i + 1;
final isCurrent = page == widget.currentPage;
final bytes = widget.thumbs[page];
return GestureDetector(
onTap: () => widget.onTap(page),
child: Container(
width: 52,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: isCurrent ? pt.accent : pt.border,
width: isCurrent ? 2 : 1,
),
),
clipBehavior: Clip.hardEdge,
child: bytes != null
? Image.memory(bytes, fit: BoxFit.cover)
: Center(
child: Text('$page', style: TextStyle(color: pt.fgDim, fontSize: 10)),
),
),
);
},
),
);
}
}
// ─── 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 = <ArchiveFile>[];
for (final f in archive.files) {
if (!f.isFile) continue;
final n = f.name.toLowerCase();
if (ext == 'docx' && n == 'word/document.xml') { xmlFiles.add(f); break; }
if (ext == 'xlsx' && n.startsWith('xl/worksheets/') && n.endsWith('.xml')) xmlFiles.add(f);
if (ext == 'pptx' && n.startsWith('ppt/slides/slide') && n.endsWith('.xml')) xmlFiles.add(f);
// ODF formats (Writer/Impress/Calc) all use content.xml
if ((ext == 'odt' || ext == 'odp' || ext == 'ods') && n == 'content.xml') { xmlFiles.add(f); break; }
}
final buf = StringBuffer();
for (final f in xmlFiles) {
final xml = utf8.decode(f.content as List<int>, allowMalformed: true);
// Strip XML tags, collapse whitespace
final stripped = xml
.replaceAll(RegExp(r'<[^>]+>'), ' ')
.replaceAll(RegExp(r'&amp;'), '&')
.replaceAll(RegExp(r'&lt;'), '<')
.replaceAll(RegExp(r'&gt;'), '>')
.replaceAll(RegExp(r'&nbsp;'), ' ')
.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<int>(0, (s, e) => s + (e.isDir ? 0 : e.size));
return Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: pt.bg1,
child: Row(
children: [
Icon(Icons.folder_zip_outlined, size: 15, color: pt.fgDim),
const SizedBox(width: 8),
Text('${entries.length} Einträge · ${_fmtSize(totalBytes)} unkomprimiert', style: TextStyle(color: pt.fgDim, fontSize: 12)),
],
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: entries.length,
itemBuilder: (ctx, i) {
final e = entries[i];
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
leading: Icon(e.isDir ? Icons.folder_outlined : _fileIcon(e.name), size: 18, color: e.isDir ? pt.accent : pt.fgDim),
title: Text(e.name, style: TextStyle(color: pt.fg, fontSize: 13), overflow: TextOverflow.ellipsis),
trailing: e.isDir ? null : Text(_fmtSize(e.size), style: TextStyle(color: pt.fgDim, fontSize: 11)),
);
},
),
),
],
);
}
IconData _fileIcon(String name) {
final ext = name.contains('.') ? name.split('.').last.toLowerCase() : '';
return switch (ext) {
'dart' || 'py' || 'js' || 'ts' || 'json' || 'xml' || 'yaml' || 'toml' => Icons.code_rounded,
'png' || 'jpg' || 'jpeg' || 'gif' || 'webp' || 'svg' => Icons.image_outlined,
'mp4' || 'mov' || 'avi' || 'mkv' => Icons.videocam_outlined,
'mp3' || 'ogg' || 'flac' || 'wav' => Icons.music_note_outlined,
'pdf' => Icons.picture_as_pdf_outlined,
'zip' || 'tar' || 'gz' || '7z' || 'rar' => Icons.folder_zip_outlined,
'md' || 'markdown' => Icons.article_outlined,
_ => Icons.insert_drive_file_outlined,
};
}
}
class _ArchiveEntry {
final String name;
final int size;
final bool isDir;
const _ArchiveEntry({required this.name, required this.size, required this.isDir});
}
// ─── 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<int>);
_isImage = true;
_loaded = true;
});
} else {
setState(() => _loaded = true);
}
} catch (_) {
setState(() => _loaded = true);
}
}
@override
Widget build(BuildContext context) {
final pt = widget.pt;
if (!_loaded) {
return Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent));
}
if (_isImage && _preview != null) {
return Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
color: pt.bg2,
child: Row(
children: [
Icon(Icons.info_outline_rounded, size: 14, color: pt.fgDim),
const SizedBox(width: 8),
Text('Eingebettete Vorschau', style: TextStyle(color: pt.fgDim, fontSize: 12)),
],
),
),
Expanded(
child: InteractiveViewer(
minScale: 0.5,
maxScale: 10,
child: Padding(
padding: const EdgeInsets.all(24),
child: Image.memory(_preview!, fit: BoxFit.contain),
),
),
),
],
);
}
// No preview image found — fall back to archive listing
return _ArchiveViewer(bytes: widget.bytes, filename: widget.filename, pt: pt);
}
}
// ─── 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,
};
}