Files
pyramid/lib/features/chat/document/document_viewer_pdf.dart
T
Bernd Steckmeister f9979e4f32 fix: beim Gott-Datei-Split verlorene Erklärkommentare wiederhergestellt
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>
2026-07-03 14:37:48 +02:00

815 lines
27 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)),
),
),
);
},
),
);
}
}