// lib/editor/ui/thumbnail_grid.dart // // Drawboard-style page thumbnail grid for BadNote. // Shows lazy GridView of PDF page thumbnails via PdfPageView. // Used as a modal bottom sheet via showPageThumbnailSheet(). import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:pdfrx/pdfrx.dart'; /// A lazy grid of page thumbnails for a PDF document. /// /// [document] — the open PdfDocument. /// [currentPage] — 0-based index of the currently active page. /// [onPageSelected] — called with the 0-based page index when the user taps a thumbnail. class PageThumbnailGrid extends StatefulWidget { const PageThumbnailGrid({ super.key, required this.document, required this.currentPage, required this.onPageSelected, }); final PdfDocument document; final int currentPage; final ValueChanged onPageSelected; @override State createState() => _PageThumbnailGridState(); } class _PageThumbnailGridState extends State { late final ScrollController _scrollController; /// Approximate tile height used for the initial jump calculation. /// The grid uses a cross-axis count of 3, so tile width ≈ screenWidth/3. /// We rely on a fixed thumbnail width of ~120 logical pixels so the /// aspect ratio (A4 ≈ 1:√2) gives us roughly 170 px tall per tile. static const double _tileHeightEstimate = 170.0; static const double _crossAxisCount = 3; static const double _thumbnailWidth = 120.0; @override void initState() { super.initState(); _scrollController = ScrollController(); SchedulerBinding.instance.addPostFrameCallback((_) => _jumpToCurrentPage()); } @override void didUpdateWidget(covariant PageThumbnailGrid oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.currentPage != widget.currentPage) { SchedulerBinding.instance.addPostFrameCallback((_) => _jumpToCurrentPage()); } } void _jumpToCurrentPage() { if (!_scrollController.hasClients) return; final row = widget.currentPage ~/ _crossAxisCount.toInt(); final offset = row * _tileHeightEstimate; final maxOffset = _scrollController.position.maxScrollExtent; _scrollController.jumpTo(offset.clamp(0.0, maxOffset)); } @override void dispose() { _scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final pageCount = widget.document.pages.length; return GridView.builder( controller: _scrollController, padding: const EdgeInsets.all(12), gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: _thumbnailWidth + 24, mainAxisSpacing: 12, crossAxisSpacing: 12, childAspectRatio: _thumbnailWidth / _tileHeightEstimate, ), itemCount: pageCount, itemBuilder: (context, index) { return _ThumbnailTile( document: widget.document, pageIndex: index, isSelected: index == widget.currentPage, onTap: () => widget.onPageSelected(index), ); }, ); } } class _ThumbnailTile extends StatelessWidget { const _ThumbnailTile({ required this.document, required this.pageIndex, required this.isSelected, required this.onTap, }); final PdfDocument document; final int pageIndex; final bool isSelected; final VoidCallback onTap; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 150), decoration: BoxDecoration( color: isSelected ? colorScheme.secondaryContainer : colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(8), border: Border.all( color: isSelected ? colorScheme.primary : Colors.transparent, width: 2, ), ), child: ClipRRect( borderRadius: BorderRadius.circular(6), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( child: PdfPageView( document: document, pageNumber: pageIndex + 1, // PdfPageView is 1-based backgroundColor: colorScheme.surface, decoration: const BoxDecoration(color: Colors.white), ), ), Container( padding: const EdgeInsets.symmetric(vertical: 4), color: isSelected ? colorScheme.secondaryContainer : colorScheme.surfaceContainerHigh, child: Text( '${pageIndex + 1}', textAlign: TextAlign.center, style: Theme.of(context).textTheme.labelSmall?.copyWith( color: isSelected ? colorScheme.onSecondaryContainer : colorScheme.onSurfaceVariant, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, ), ), ), ], ), ), ), ); } } /// Shows a Material You modal bottom sheet containing a [PageThumbnailGrid]. /// /// [document] — the open PdfDocument. /// [currentPage] — 0-based index of the currently active page. /// [onPageSelected] — called with the 0-based page index when the user selects /// a thumbnail; the sheet is automatically dismissed afterwards. Future showPageThumbnailSheet( BuildContext context, { required PdfDocument document, required int currentPage, required ValueChanged onPageSelected, }) { return showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (sheetContext) { return DraggableScrollableSheet( initialChildSize: 0.7, minChildSize: 0.4, maxChildSize: 0.95, expand: false, builder: (_, scrollController) { return Column( children: [ // Drag handle Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Container( width: 40, height: 4, decoration: BoxDecoration( color: Theme.of(sheetContext).colorScheme.outlineVariant, borderRadius: BorderRadius.circular(2), ), ), ), // Header row Padding( padding: const EdgeInsets.fromLTRB(20, 0, 8, 8), child: Row( children: [ Text( 'Pages', style: Theme.of(sheetContext).textTheme.titleMedium, ), const Spacer(), IconButton( icon: const Icon(Icons.close), tooltip: 'Close', onPressed: () => Navigator.of(sheetContext).pop(), ), ], ), ), const Divider(height: 1), // Thumbnail grid — fill remaining height Expanded( child: PageThumbnailGrid( document: document, currentPage: currentPage, onPageSelected: (index) { Navigator.of(sheetContext).pop(); onPageSelected(index); }, ), ), ], ); }, ); }, ); }