f9979e4f32
Qualitäts-Review-Befund (a): Alle 6 part/part-of-Aufteilungen hatten Code vollständig übernommen (Zeilen-Multiset-Vergleich Original vs. heutiger Stand: keine Code-Zeile verloren, keine hinzugefügt), ABER erklärende Kommentare über den Klassen gingen verloren (24 Blöcke, u. a. die HTML-Subset-Doku über _MatrixHtmlText, die Selbst-Aussperr-Warnung über updatePowerLevelsSafely, TURN-Verbrauchskarte, PDF-Cache/Viewer-Doku). Alle an den richtigen Klassen wiederhergestellt; reine Trennlinien und ALL-CAPS-Abschnittslabels bewusst nicht (Gott-Datei-Navigation, durch die neuen Dateinamen ersetzt). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
815 lines
27 KiB
Dart
815 lines
27 KiB
Dart
part of '../document_viewer.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;
|
||
}
|
||
}
|
||
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')));
|
||
}
|
||
}
|
||
}
|
||
// 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 _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.
|
||
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)),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|