401bd6a7f8
- document_viewer: Matrix4.translate/scale durch translateByDouble/ scaleByDouble ersetzt (PDF-Zoom-Transformation, gleiches Verhalten) - rooms_panel: onReorder durch onReorderItem ersetzt (Index-Korrektur jetzt intern erledigt), implementation_imports-Hinweis kommentiert (SpaceChild wird vom matrix-Paket nicht öffentlich exportiert) - voip_manager: getSources()-Override kommentiert (erzwungen durch abstrakte MediaDevices-Klasse, die die Methode selbst deprecated hat) - settings_modal: BuildContext-Nutzung nach await mit context.mounted abgesichert (Geräte umbenennen, Passwort-Abfrage bei Massenlogout) Verbleibend: chat_provider.dart onUpdate (Timeline-Redecrypt-Pfad, bewusst nicht angefasst ohne dedizierten E2EE-Test, siehe PROGRESS.md).
1774 lines
62 KiB
Dart
1774 lines
62 KiB
Dart
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()
|
||
..translateByDouble(cx, cy, 0, 1)
|
||
..scaleByDouble(applied, applied, applied, 1)
|
||
..translateByDouble(-cx, -cy, 0, 1);
|
||
setState(() => _tc.value = m);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_doc?.close();
|
||
_tc.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final pt = PyramidTheme.of(context);
|
||
return Scaffold(
|
||
backgroundColor: Colors.black,
|
||
body: SafeArea(
|
||
child: Stack(
|
||
children: [
|
||
Positioned.fill(
|
||
child: GestureDetector(
|
||
// Single tap on the page (not on a control) toggles the UI.
|
||
behavior: HitTestBehavior.translucent,
|
||
onTap: () => setState(() => _uiVisible = !_uiVisible),
|
||
child: Listener(
|
||
onPointerSignal: (e) {
|
||
if (e is! PointerScrollEvent) return;
|
||
final ctrl = HardwareKeyboard.instance.isControlPressed ||
|
||
HardwareKeyboard.instance.isMetaPressed;
|
||
if (ctrl) {
|
||
_zoom(e.scrollDelta.dy < 0 ? 1.2 : 1 / 1.2);
|
||
} else {
|
||
_goto(e.scrollDelta.dy > 0 ? _page + 1 : _page - 1);
|
||
}
|
||
},
|
||
child: _img == null
|
||
? Center(
|
||
child: _loading
|
||
? CircularProgressIndicator(color: pt.accent)
|
||
: const Icon(Icons.error_outline_rounded, color: Colors.white54, size: 40),
|
||
)
|
||
: InteractiveViewer(
|
||
key: _viewerKey,
|
||
transformationController: _tc,
|
||
minScale: _minScale,
|
||
maxScale: _maxScale,
|
||
// Pinch-zoom on mobile only. On desktop the built-in
|
||
// scroll-wheel zoom is disabled so plain scrolling
|
||
// pages instead; zoom is Ctrl+wheel and the +/− buttons.
|
||
scaleEnabled: _isMobile,
|
||
child: Center(
|
||
child: Image.memory(_img!, fit: BoxFit.contain, gaplessPlayback: true),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// Right-side control column
|
||
if (_uiVisible)
|
||
Positioned(
|
||
right: 10,
|
||
top: 0,
|
||
bottom: 0,
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_FsBtn(icon: Icons.add_rounded, tooltip: 'Vergrößern', pt: pt, onTap: () => _zoom(1.2)),
|
||
const SizedBox(height: 8),
|
||
_FsBtn(icon: Icons.remove_rounded, tooltip: 'Verkleinern', pt: pt, onTap: () => _zoom(1 / 1.2)),
|
||
const SizedBox(height: 8),
|
||
_FsBtn(icon: Icons.download_rounded, tooltip: 'Herunterladen', pt: pt,
|
||
onTap: () => _saveBytes(context, widget.bytes, widget.filename)),
|
||
const SizedBox(height: 8),
|
||
_FsBtn(icon: Icons.fullscreen_exit_rounded, tooltip: 'Vollbild beenden', pt: pt,
|
||
onTap: () => Navigator.of(context).maybePop()),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
|
||
// Bottom-center page navigation
|
||
if (_uiVisible && _count > 1)
|
||
Positioned(
|
||
bottom: 16,
|
||
left: 0,
|
||
right: 0,
|
||
child: Center(
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black.withAlpha(160),
|
||
borderRadius: BorderRadius.circular(24),
|
||
border: Border.all(color: Colors.white24),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_FsBtn(icon: Icons.chevron_left_rounded, tooltip: 'Vorherige', pt: pt,
|
||
enabled: _page > 1, onTap: () => _goto(_page - 1), flat: true),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||
child: Text('$_page / $_count',
|
||
style: const TextStyle(color: Colors.white, fontSize: 13,
|
||
fontFeatures: [FontFeature.tabularFigures()])),
|
||
),
|
||
_FsBtn(icon: Icons.chevron_right_rounded, tooltip: 'Nächste', pt: pt,
|
||
enabled: _page < _count, onTap: () => _goto(_page + 1), flat: true),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _FsBtn extends StatelessWidget {
|
||
final IconData icon;
|
||
final String tooltip;
|
||
final PyramidTheme pt;
|
||
final VoidCallback onTap;
|
||
final bool enabled;
|
||
final bool flat;
|
||
const _FsBtn({required this.icon, required this.tooltip, required this.pt,
|
||
required this.onTap, this.enabled = true, this.flat = false});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final btn = Material(
|
||
color: flat ? Colors.transparent : Colors.black.withAlpha(160),
|
||
shape: flat
|
||
? const CircleBorder()
|
||
: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10), side: const BorderSide(color: Colors.white24)),
|
||
child: IconButton(
|
||
tooltip: tooltip,
|
||
icon: Icon(icon, color: enabled ? Colors.white : Colors.white30, size: 22),
|
||
onPressed: enabled ? onTap : null,
|
||
),
|
||
);
|
||
return btn;
|
||
}
|
||
}
|
||
|
||
class _ThumbnailStrip extends StatefulWidget {
|
||
final int pageCount;
|
||
final int currentPage;
|
||
final Map<int, Uint8List?> thumbs;
|
||
final PyramidTheme pt;
|
||
final ValueChanged<int> onTap;
|
||
const _ThumbnailStrip({required this.pageCount, required this.currentPage, required this.thumbs, required this.pt, required this.onTap});
|
||
|
||
@override
|
||
State<_ThumbnailStrip> createState() => _ThumbnailStripState();
|
||
}
|
||
|
||
class _ThumbnailStripState extends State<_ThumbnailStrip> {
|
||
late final ScrollController _sc;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_sc = ScrollController();
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(_ThumbnailStrip old) {
|
||
super.didUpdateWidget(old);
|
||
if (old.currentPage != widget.currentPage) {
|
||
_scrollToPage(widget.currentPage);
|
||
}
|
||
}
|
||
|
||
void _scrollToPage(int page) {
|
||
const thumbW = 60.0 + 8.0; // thumb + margin
|
||
final target = (page - 1) * thumbW - 80;
|
||
if (_sc.hasClients) {
|
||
_sc.animateTo(
|
||
target.clamp(0.0, _sc.position.maxScrollExtent),
|
||
duration: const Duration(milliseconds: 200),
|
||
curve: Curves.easeOut,
|
||
);
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_sc.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final pt = widget.pt;
|
||
return Container(
|
||
height: 88,
|
||
decoration: BoxDecoration(
|
||
color: pt.bg1,
|
||
border: Border(top: BorderSide(color: pt.border)),
|
||
),
|
||
child: ListView.builder(
|
||
controller: _sc,
|
||
scrollDirection: Axis.horizontal,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||
itemCount: widget.pageCount,
|
||
itemBuilder: (ctx, i) {
|
||
final page = i + 1;
|
||
final isCurrent = page == widget.currentPage;
|
||
final bytes = widget.thumbs[page];
|
||
return GestureDetector(
|
||
onTap: () => widget.onTap(page),
|
||
child: Container(
|
||
width: 52,
|
||
margin: const EdgeInsets.only(right: 8),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(4),
|
||
border: Border.all(
|
||
color: isCurrent ? pt.accent : pt.border,
|
||
width: isCurrent ? 2 : 1,
|
||
),
|
||
),
|
||
clipBehavior: Clip.hardEdge,
|
||
child: bytes != null
|
||
? Image.memory(bytes, fit: BoxFit.cover)
|
||
: Center(
|
||
child: Text('$page', style: TextStyle(color: pt.fgDim, fontSize: 10)),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─── 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'&'), '&')
|
||
.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<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,
|
||
};
|
||
}
|