refactor: Gott-Datei document_viewer.dart in 5 Dateien aufgeteilt
Sechster Durchgang derselben part/part-of-Technik wie bei den 5 vorigen Umbauten - reine Verschiebung, keine Logikänderung. document_viewer.dart schrumpft von 1773 auf 20 Zeilen (nur noch Bibliothekskopf), 34 Top-Level-Bloecke (31 Klassen/Enums + 3 lose Funktionen) auf 5 thematische Dateien unter lib/features/chat/document/ verteilt. Verifiziert per automatisiertem Blockvergleich gegen das Original und flutter analyze (weiterhin nur 1 bekannter Hinweis). voice_channel.dart und app_shell.dart bewusst nicht angefasst - haengen an der noch unentschiedenen Call-Fassade bzw. dem Bootstrap/Login-Pfad. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,803 @@
|
||||
part of '../document_viewer.dart';
|
||||
|
||||
class _RenderCache {
|
||||
static const _max = 80;
|
||||
static final _data = <String, Uint8List>{};
|
||||
|
||||
static String _k(String name, int sz, int page, int w, int h) =>
|
||||
'$name\x00$sz\x00$page\x00${w}x$h';
|
||||
|
||||
static Uint8List? get(String name, int sz, int page, int w, int h) =>
|
||||
_data[_k(name, sz, page, w, h)];
|
||||
|
||||
static void put(String name, int sz, int page, int w, int h, Uint8List img) {
|
||||
if (_data.length >= _max) _data.remove(_data.keys.first);
|
||||
_data[_k(name, sz, page, w, h)] = img;
|
||||
}
|
||||
}
|
||||
Future<void> _saveBytes(BuildContext context, Uint8List bytes, String filename) async {
|
||||
try {
|
||||
Directory? dir;
|
||||
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
|
||||
dir = await getDownloadsDirectory();
|
||||
}
|
||||
dir ??= await getTemporaryDirectory();
|
||||
final ext = filename.contains('.') ? filename.split('.').last : 'bin';
|
||||
final name = 'pyramid_${DateTime.now().millisecondsSinceEpoch}.$ext';
|
||||
final file = File('${dir.path}/$name');
|
||||
await file.writeAsBytes(bytes);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Gespeichert: ${file.path}'), duration: const Duration(seconds: 4)),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
class PdfInlinePreview extends StatefulWidget {
|
||||
final Uint8List bytes;
|
||||
final String filename;
|
||||
final PyramidTheme pt;
|
||||
final String senderName;
|
||||
final VoidCallback? onDownload;
|
||||
|
||||
const PdfInlinePreview({
|
||||
super.key,
|
||||
required this.bytes,
|
||||
required this.filename,
|
||||
required this.pt,
|
||||
required this.senderName,
|
||||
this.onDownload,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PdfInlinePreview> createState() => _PdfInlinePreviewState();
|
||||
}
|
||||
class _PdfInlinePreviewState extends State<PdfInlinePreview> {
|
||||
PdfDocument? _doc;
|
||||
int _currentPage = 1;
|
||||
int _pageCount = 0;
|
||||
double? _pageAspect; // width/height
|
||||
final Map<int, Uint8List?> _cache = {};
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
bool _rendering = false;
|
||||
|
||||
static const _previewW = 280.0;
|
||||
static const _renderScale = 2.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_init();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
try {
|
||||
final doc = await PdfDocument.openData(widget.bytes);
|
||||
if (!mounted) { await doc.close(); return; }
|
||||
_doc = doc;
|
||||
_pageCount = doc.pagesCount;
|
||||
setState(() => _loading = false);
|
||||
await _ensurePage(1);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _loading = false; _error = e.toString().split('\n').first; });
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ensurePage(int n) async {
|
||||
if (_cache.containsKey(n) || _rendering) return;
|
||||
_rendering = true;
|
||||
try {
|
||||
final page = await _doc!.getPage(n);
|
||||
if (mounted && _pageAspect == null) {
|
||||
setState(() => _pageAspect = page.width / page.height);
|
||||
}
|
||||
final rw = (page.width * _renderScale).round();
|
||||
final rh = (page.height * _renderScale).round();
|
||||
final hit = _RenderCache.get(widget.filename, widget.bytes.length, n, rw, rh);
|
||||
if (hit != null) {
|
||||
await page.close();
|
||||
if (mounted) setState(() => _cache[n] = hit);
|
||||
return;
|
||||
}
|
||||
final img = await page.render(
|
||||
width: page.width * _renderScale,
|
||||
height: page.height * _renderScale,
|
||||
format: PdfPageImageFormat.jpeg,
|
||||
backgroundColor: '#ffffff',
|
||||
);
|
||||
await page.close();
|
||||
if (img?.bytes != null) {
|
||||
_RenderCache.put(widget.filename, widget.bytes.length, n, rw, rh, img!.bytes);
|
||||
}
|
||||
if (mounted) setState(() => _cache[n] = img?.bytes);
|
||||
} catch (_) {
|
||||
_cache[n] = null;
|
||||
} finally {
|
||||
_rendering = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _goTo(int page) {
|
||||
if (page < 1 || page > _pageCount) return;
|
||||
setState(() => _currentPage = page);
|
||||
_ensurePage(page);
|
||||
_ensurePage((page + 1).clamp(1, _pageCount));
|
||||
}
|
||||
|
||||
void _openFull() {
|
||||
DocumentViewer.show(
|
||||
context,
|
||||
filename: widget.filename,
|
||||
bytes: widget.bytes,
|
||||
senderName: widget.senderName,
|
||||
initialPage: _currentPage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_doc?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
final radius = BorderRadius.circular(pt.rBase);
|
||||
|
||||
if (_loading) {
|
||||
return Container(
|
||||
width: _previewW,
|
||||
// Match the typical loaded A4 height so the preview doesn't jump (and
|
||||
// shift the scroll position) once the first page finishes rendering.
|
||||
height: (_previewW / 0.707).clamp(160.0, 400.0),
|
||||
margin: const EdgeInsets.only(top: 4, bottom: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg2,
|
||||
borderRadius: radius,
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
child: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)),
|
||||
);
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return _FileFallback(filename: widget.filename, pt: pt, onDownload: widget.onDownload);
|
||||
}
|
||||
|
||||
final pageBytes = _cache[_currentPage];
|
||||
final aspect = _pageAspect ?? 0.707; // A4 fallback
|
||||
final imgH = (_previewW / aspect).clamp(160.0, 400.0);
|
||||
|
||||
return Listener(
|
||||
onPointerSignal: (event) {
|
||||
if (event is PointerScrollEvent) {
|
||||
GestureBinding.instance.pointerSignalResolver.register(event, (resolved) {
|
||||
final dy = (resolved as PointerScrollEvent).scrollDelta.dy;
|
||||
if (dy > 0) { _goTo(_currentPage + 1); }
|
||||
else if (dy < 0) { _goTo(_currentPage - 1); }
|
||||
});
|
||||
}
|
||||
},
|
||||
child: GestureDetector(
|
||||
onTap: _openFull,
|
||||
onHorizontalDragEnd: (d) {
|
||||
if ((d.primaryVelocity ?? 0) < -200) _goTo(_currentPage + 1);
|
||||
if ((d.primaryVelocity ?? 0) > 200) _goTo(_currentPage - 1);
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 4, bottom: 4),
|
||||
width: _previewW,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: radius,
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Page image
|
||||
Container(
|
||||
width: _previewW,
|
||||
height: imgH,
|
||||
color: Colors.white,
|
||||
child: pageBytes != null
|
||||
? Image.memory(pageBytes, width: _previewW, height: imgH, fit: BoxFit.cover, alignment: Alignment.topCenter)
|
||||
: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)),
|
||||
),
|
||||
// Footer
|
||||
Container(
|
||||
color: pt.bg2,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.picture_as_pdf_outlined, size: 14, color: pt.fgDim),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.filename,
|
||||
style: TextStyle(color: pt.fg, fontSize: 12, fontWeight: FontWeight.w500),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (_pageCount > 1) ...[
|
||||
_NavArrow(
|
||||
icon: Icons.chevron_left_rounded,
|
||||
enabled: _currentPage > 1,
|
||||
pt: pt,
|
||||
onTap: () => _goTo(_currentPage - 1),
|
||||
),
|
||||
Text(
|
||||
'$_currentPage/$_pageCount',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 11, fontFeatures: const [FontFeature.tabularFigures()]),
|
||||
),
|
||||
_NavArrow(
|
||||
icon: Icons.chevron_right_rounded,
|
||||
enabled: _currentPage < _pageCount,
|
||||
pt: pt,
|
||||
onTap: () => _goTo(_currentPage + 1),
|
||||
),
|
||||
],
|
||||
if (widget.onDownload != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
GestureDetector(
|
||||
onTap: widget.onDownload,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: Icon(Icons.download_rounded, size: 16, color: pt.fgMuted),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
class _NavArrow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final bool enabled;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback onTap;
|
||||
const _NavArrow({required this.icon, required this.enabled, required this.pt, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
onTap: enabled ? onTap : null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Icon(icon, size: 18, color: enabled ? pt.fg : pt.fgDim),
|
||||
),
|
||||
);
|
||||
}
|
||||
class _PdfFullViewer extends StatefulWidget {
|
||||
final Uint8List bytes;
|
||||
final String filename;
|
||||
final PyramidTheme pt;
|
||||
final int initialPage;
|
||||
const _PdfFullViewer({required this.bytes, required this.filename, required this.pt, required this.initialPage});
|
||||
|
||||
@override
|
||||
State<_PdfFullViewer> createState() => _PdfFullViewerState();
|
||||
}
|
||||
class _PdfFullViewerState extends State<_PdfFullViewer> {
|
||||
late final PdfController _ctrl;
|
||||
PdfDocument? _thumbDoc;
|
||||
final Map<int, Uint8List?> _thumbs = {};
|
||||
int _pageCount = 0;
|
||||
int _currentPage = 1;
|
||||
bool _animating = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentPage = widget.initialPage;
|
||||
_ctrl = PdfController(
|
||||
document: PdfDocument.openData(widget.bytes),
|
||||
initialPage: widget.initialPage,
|
||||
);
|
||||
_ctrl.pageListenable.addListener(_onPage);
|
||||
_initThumbs();
|
||||
}
|
||||
|
||||
Future<void> _initThumbs() async {
|
||||
final doc = await PdfDocument.openData(widget.bytes);
|
||||
if (!mounted) { await doc.close(); return; }
|
||||
_thumbDoc = doc;
|
||||
if (!mounted) { await doc.close(); _thumbDoc = null; return; }
|
||||
setState(() => _pageCount = doc.pagesCount);
|
||||
for (var i = 1; i <= doc.pagesCount; i++) {
|
||||
_renderThumb(i);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _renderThumb(int n) async {
|
||||
final doc = _thumbDoc;
|
||||
if (doc == null) return;
|
||||
try {
|
||||
final page = await doc.getPage(n);
|
||||
final rw = (page.width * 0.12).round();
|
||||
final rh = (page.height * 0.12).round();
|
||||
final hit = _RenderCache.get(widget.filename, widget.bytes.length, n, rw, rh);
|
||||
if (hit != null) {
|
||||
await page.close();
|
||||
if (mounted) setState(() => _thumbs[n] = hit);
|
||||
return;
|
||||
}
|
||||
final img = await page.render(
|
||||
width: page.width * 0.12,
|
||||
height: page.height * 0.12,
|
||||
format: PdfPageImageFormat.jpeg,
|
||||
backgroundColor: '#ffffff',
|
||||
);
|
||||
await page.close();
|
||||
if (img?.bytes != null) {
|
||||
_RenderCache.put(widget.filename, widget.bytes.length, n, rw, rh, img!.bytes);
|
||||
}
|
||||
if (mounted) setState(() => _thumbs[n] = img?.bytes);
|
||||
} catch (_) {
|
||||
_thumbs[n] = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onPage() => setState(() => _currentPage = _ctrl.page);
|
||||
|
||||
void _onScrollPage(int delta) {
|
||||
if (_animating) return;
|
||||
final pageCount = _ctrl.pagesCount ?? _pageCount;
|
||||
if (pageCount == 0) return;
|
||||
final target = (_currentPage + delta).clamp(1, pageCount);
|
||||
if (target == _currentPage) return;
|
||||
_animating = true;
|
||||
_ctrl.animateToPage(
|
||||
target,
|
||||
duration: const Duration(milliseconds: 280),
|
||||
curve: Curves.easeInOut,
|
||||
).then((_) { if (mounted) setState(() => _animating = false); });
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.pageListenable.removeListener(_onPage);
|
||||
_ctrl.dispose();
|
||||
_thumbDoc?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Listener(
|
||||
onPointerSignal: (event) {
|
||||
if (event is PointerScrollEvent) {
|
||||
if (event.scrollDelta.dy > 0) { _onScrollPage(1); }
|
||||
else if (event.scrollDelta.dy < 0) { _onScrollPage(-1); }
|
||||
}
|
||||
},
|
||||
child: PdfView(
|
||||
controller: _ctrl,
|
||||
scrollDirection: Axis.vertical,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
backgroundDecoration: BoxDecoration(color: pt.bg0),
|
||||
onPageChanged: (p) => setState(() => _currentPage = p),
|
||||
// Render at high resolution in PNG: sharper text/line-art and no
|
||||
// JPEG chroma artifacts (matters most for AI/EPS/vector content),
|
||||
// and gives headroom for the InteractiveViewer zoom.
|
||||
renderer: (page) => page.render(
|
||||
width: page.width * 4,
|
||||
height: page.height * 4,
|
||||
format: PdfPageImageFormat.png,
|
||||
backgroundColor: '#ffffff',
|
||||
),
|
||||
builders: PdfViewBuilders<DefaultBuilderOptions>(
|
||||
options: const DefaultBuilderOptions(),
|
||||
documentLoaderBuilder: (ctx) => Center(
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent),
|
||||
),
|
||||
errorBuilder: (ctx, error) => _ErrorView(message: error.toString(), pt: pt),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Launch the immersive fullscreen page viewer.
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Material(
|
||||
color: pt.bg0.withAlpha(200),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(pt.rSm),
|
||||
side: BorderSide(color: pt.border),
|
||||
),
|
||||
child: IconButton(
|
||||
tooltip: 'Vollbild',
|
||||
icon: Icon(Icons.fullscreen_rounded, size: 22, color: pt.fg),
|
||||
onPressed: () => _PdfFullscreenViewer.show(
|
||||
context,
|
||||
bytes: widget.bytes,
|
||||
filename: widget.filename,
|
||||
initialPage: _currentPage,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_pageCount > 1) _ThumbnailStrip(
|
||||
pageCount: _pageCount,
|
||||
currentPage: _currentPage,
|
||||
thumbs: _thumbs,
|
||||
pt: pt,
|
||||
onTap: (p) {
|
||||
_ctrl.jumpToPage(p);
|
||||
setState(() => _currentPage = p);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
class _PdfFullscreenViewer extends StatefulWidget {
|
||||
final Uint8List bytes;
|
||||
final String filename;
|
||||
final int initialPage;
|
||||
const _PdfFullscreenViewer({required this.bytes, required this.filename, required this.initialPage});
|
||||
|
||||
static Future<void> show(BuildContext context, {
|
||||
required Uint8List bytes,
|
||||
required String filename,
|
||||
required int initialPage,
|
||||
}) {
|
||||
return Navigator.of(context, rootNavigator: true).push(PageRouteBuilder(
|
||||
opaque: true,
|
||||
barrierColor: Colors.black,
|
||||
transitionDuration: const Duration(milliseconds: 180),
|
||||
pageBuilder: (_, _, _) =>
|
||||
_PdfFullscreenViewer(bytes: bytes, filename: filename, initialPage: initialPage),
|
||||
transitionsBuilder: (_, anim, _, child) => FadeTransition(opacity: anim, child: child),
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
State<_PdfFullscreenViewer> createState() => _PdfFullscreenViewerState();
|
||||
}
|
||||
class _PdfFullscreenViewerState extends State<_PdfFullscreenViewer> {
|
||||
PdfDocument? _doc;
|
||||
final _tc = TransformationController();
|
||||
final _viewerKey = GlobalKey();
|
||||
int _page = 1;
|
||||
int _count = 0;
|
||||
Uint8List? _img;
|
||||
bool _loading = true;
|
||||
bool _rendering = false;
|
||||
bool _uiVisible = true;
|
||||
|
||||
bool get _isMobile => Platform.isAndroid || Platform.isIOS;
|
||||
|
||||
static const _minScale = 1.0;
|
||||
static const _maxScale = 8.0;
|
||||
static const _renderScale = 5.0; // high-res page render for crisp zoom
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_page = widget.initialPage;
|
||||
_open();
|
||||
}
|
||||
|
||||
Future<void> _open() async {
|
||||
try {
|
||||
final doc = await PdfDocument.openData(widget.bytes);
|
||||
if (!mounted) { await doc.close(); return; }
|
||||
_doc = doc;
|
||||
_count = doc.pagesCount;
|
||||
await _render(_page);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _render(int n) async {
|
||||
final doc = _doc;
|
||||
if (doc == null || _rendering) return;
|
||||
_rendering = true;
|
||||
if (mounted) setState(() => _loading = true);
|
||||
try {
|
||||
final page = await doc.getPage(n);
|
||||
final rw = (page.width * _renderScale).round();
|
||||
final rh = (page.height * _renderScale).round();
|
||||
var bytes = _RenderCache.get(widget.filename, widget.bytes.length, n, rw, rh);
|
||||
if (bytes == null) {
|
||||
final img = await page.render(
|
||||
width: page.width * _renderScale,
|
||||
height: page.height * _renderScale,
|
||||
format: PdfPageImageFormat.png,
|
||||
backgroundColor: '#ffffff',
|
||||
);
|
||||
bytes = img?.bytes;
|
||||
if (bytes != null) {
|
||||
_RenderCache.put(widget.filename, widget.bytes.length, n, rw, rh, bytes);
|
||||
}
|
||||
}
|
||||
await page.close();
|
||||
if (mounted) setState(() { _img = bytes; _loading = false; });
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
} finally {
|
||||
_rendering = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _goto(int n) {
|
||||
final t = n.clamp(1, _count);
|
||||
if (t == _page) return;
|
||||
_tc.value = Matrix4.identity(); // reset zoom on page change
|
||||
setState(() => _page = t);
|
||||
_render(t);
|
||||
}
|
||||
|
||||
// factor 1.2 = +20%, 1/1.2 = −20%
|
||||
void _zoom(double factor) {
|
||||
final size = _viewerKey.currentContext?.size;
|
||||
if (size == null) return;
|
||||
final current = _tc.value.getMaxScaleOnAxis();
|
||||
final target = (current * factor).clamp(_minScale, _maxScale);
|
||||
final applied = target / current;
|
||||
if ((applied - 1).abs() < 0.001) return;
|
||||
final cx = size.width / 2, cy = size.height / 2;
|
||||
final m = _tc.value.clone()
|
||||
..translateByDouble(cx, cy, 0, 1)
|
||||
..scaleByDouble(applied, applied, applied, 1)
|
||||
..translateByDouble(-cx, -cy, 0, 1);
|
||||
setState(() => _tc.value = m);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_doc?.close();
|
||||
_tc.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
// Single tap on the page (not on a control) toggles the UI.
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () => setState(() => _uiVisible = !_uiVisible),
|
||||
child: Listener(
|
||||
onPointerSignal: (e) {
|
||||
if (e is! PointerScrollEvent) return;
|
||||
final ctrl = HardwareKeyboard.instance.isControlPressed ||
|
||||
HardwareKeyboard.instance.isMetaPressed;
|
||||
if (ctrl) {
|
||||
_zoom(e.scrollDelta.dy < 0 ? 1.2 : 1 / 1.2);
|
||||
} else {
|
||||
_goto(e.scrollDelta.dy > 0 ? _page + 1 : _page - 1);
|
||||
}
|
||||
},
|
||||
child: _img == null
|
||||
? Center(
|
||||
child: _loading
|
||||
? CircularProgressIndicator(color: pt.accent)
|
||||
: const Icon(Icons.error_outline_rounded, color: Colors.white54, size: 40),
|
||||
)
|
||||
: InteractiveViewer(
|
||||
key: _viewerKey,
|
||||
transformationController: _tc,
|
||||
minScale: _minScale,
|
||||
maxScale: _maxScale,
|
||||
// Pinch-zoom on mobile only. On desktop the built-in
|
||||
// scroll-wheel zoom is disabled so plain scrolling
|
||||
// pages instead; zoom is Ctrl+wheel and the +/− buttons.
|
||||
scaleEnabled: _isMobile,
|
||||
child: Center(
|
||||
child: Image.memory(_img!, fit: BoxFit.contain, gaplessPlayback: true),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Right-side control column
|
||||
if (_uiVisible)
|
||||
Positioned(
|
||||
right: 10,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_FsBtn(icon: Icons.add_rounded, tooltip: 'Vergrößern', pt: pt, onTap: () => _zoom(1.2)),
|
||||
const SizedBox(height: 8),
|
||||
_FsBtn(icon: Icons.remove_rounded, tooltip: 'Verkleinern', pt: pt, onTap: () => _zoom(1 / 1.2)),
|
||||
const SizedBox(height: 8),
|
||||
_FsBtn(icon: Icons.download_rounded, tooltip: 'Herunterladen', pt: pt,
|
||||
onTap: () => _saveBytes(context, widget.bytes, widget.filename)),
|
||||
const SizedBox(height: 8),
|
||||
_FsBtn(icon: Icons.fullscreen_exit_rounded, tooltip: 'Vollbild beenden', pt: pt,
|
||||
onTap: () => Navigator.of(context).maybePop()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom-center page navigation
|
||||
if (_uiVisible && _count > 1)
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withAlpha(160),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: Colors.white24),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_FsBtn(icon: Icons.chevron_left_rounded, tooltip: 'Vorherige', pt: pt,
|
||||
enabled: _page > 1, onTap: () => _goto(_page - 1), flat: true),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Text('$_page / $_count',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13,
|
||||
fontFeatures: [FontFeature.tabularFigures()])),
|
||||
),
|
||||
_FsBtn(icon: Icons.chevron_right_rounded, tooltip: 'Nächste', pt: pt,
|
||||
enabled: _page < _count, onTap: () => _goto(_page + 1), flat: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
class _FsBtn extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback onTap;
|
||||
final bool enabled;
|
||||
final bool flat;
|
||||
const _FsBtn({required this.icon, required this.tooltip, required this.pt,
|
||||
required this.onTap, this.enabled = true, this.flat = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final btn = Material(
|
||||
color: flat ? Colors.transparent : Colors.black.withAlpha(160),
|
||||
shape: flat
|
||||
? const CircleBorder()
|
||||
: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10), side: const BorderSide(color: Colors.white24)),
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
icon: Icon(icon, color: enabled ? Colors.white : Colors.white30, size: 22),
|
||||
onPressed: enabled ? onTap : null,
|
||||
),
|
||||
);
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
class _ThumbnailStrip extends StatefulWidget {
|
||||
final int pageCount;
|
||||
final int currentPage;
|
||||
final Map<int, Uint8List?> thumbs;
|
||||
final PyramidTheme pt;
|
||||
final ValueChanged<int> onTap;
|
||||
const _ThumbnailStrip({required this.pageCount, required this.currentPage, required this.thumbs, required this.pt, required this.onTap});
|
||||
|
||||
@override
|
||||
State<_ThumbnailStrip> createState() => _ThumbnailStripState();
|
||||
}
|
||||
class _ThumbnailStripState extends State<_ThumbnailStrip> {
|
||||
late final ScrollController _sc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_sc = ScrollController();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_ThumbnailStrip old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.currentPage != widget.currentPage) {
|
||||
_scrollToPage(widget.currentPage);
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToPage(int page) {
|
||||
const thumbW = 60.0 + 8.0; // thumb + margin
|
||||
final target = (page - 1) * thumbW - 80;
|
||||
if (_sc.hasClients) {
|
||||
_sc.animateTo(
|
||||
target.clamp(0.0, _sc.position.maxScrollExtent),
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sc.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
return Container(
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg1,
|
||||
border: Border(top: BorderSide(color: pt.border)),
|
||||
),
|
||||
child: ListView.builder(
|
||||
controller: _sc,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
itemCount: widget.pageCount,
|
||||
itemBuilder: (ctx, i) {
|
||||
final page = i + 1;
|
||||
final isCurrent = page == widget.currentPage;
|
||||
final bytes = widget.thumbs[page];
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onTap(page),
|
||||
child: Container(
|
||||
width: 52,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: isCurrent ? pt.accent : pt.border,
|
||||
width: isCurrent ? 2 : 1,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: bytes != null
|
||||
? Image.memory(bytes, fit: BoxFit.cover)
|
||||
: Center(
|
||||
child: Text('$page', style: TextStyle(color: pt.fgDim, fontSize: 10)),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user