import 'package:flutter/material.dart'; import '../services/thumbnail_service.dart'; /// Vertical sidebar showing page thumbnails for quick navigation. /// /// Thumbnails are lazily generated and cached on disk. The current page is /// highlighted with a blue border, and bookmarked pages show a colored dot. class PageThumbnailSidebar extends StatefulWidget { final String documentId; final String filePath; final int pageCount; final int currentPage; final ValueChanged onPageTap; final Set bookmarkedPages; const PageThumbnailSidebar({ super.key, required this.documentId, required this.filePath, required this.pageCount, required this.currentPage, required this.onPageTap, this.bookmarkedPages = const {}, }); @override State createState() => _PageThumbnailSidebarState(); } class _PageThumbnailSidebarState extends State { /// Cached thumbnail image data keyed by page index. final Map _cache = {}; /// Pages currently being generated (to avoid duplicate work). final Set _loading = {}; /// Pages that permanently failed thumbnail generation (null result or throw). /// Skipped on subsequent rebuilds to avoid a retry storm. final Set _failed = {}; @override void didUpdateWidget(PageThumbnailSidebar oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.documentId != widget.documentId) { _cache.clear(); _loading.clear(); _failed.clear(); } } Future _loadThumbnail(int pageIndex) async { if (_cache.containsKey(pageIndex) || _loading.contains(pageIndex) || _failed.contains(pageIndex)) { return; } _loading.add(pageIndex); try { // Check disk cache first. final cached = await ThumbnailService.getCached( widget.documentId, pageIndex, ); if (cached != null && mounted) { setState(() { _cache[pageIndex] = FileImage(cached); }); _loading.remove(pageIndex); return; } // Generate from the PDF. final bytes = await ThumbnailService.generate( widget.filePath, pageIndex, maxWidth: 160, ); if (bytes != null) { await ThumbnailService.cacheThumbnail( widget.documentId, pageIndex, bytes, ); if (mounted) { setState(() { _cache[pageIndex] = MemoryImage(bytes); }); } } else { // Null result means generation failed permanently for this page. _failed.add(pageIndex); } } catch (_) { // Any exception is treated as a permanent failure to avoid retry storms. _failed.add(pageIndex); } finally { _loading.remove(pageIndex); } } @override Widget build(BuildContext context) { return Container( width: 120, decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, border: Border( right: BorderSide(color: Theme.of(context).dividerColor, width: 1), ), ), child: ListView.builder( padding: const EdgeInsets.symmetric(vertical: 8), itemCount: widget.pageCount, itemBuilder: (context, index) { _loadThumbnail(index); final isCurrentPage = index == widget.currentPage; final isBookmarked = widget.bookmarkedPages.contains(index); return GestureDetector( onTap: () => widget.onPageTap(index), child: Container( margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( border: Border.all( color: isCurrentPage ? Theme.of(context).colorScheme.primary : Colors.grey.shade400, width: isCurrentPage ? 2.5 : 1.0, ), borderRadius: BorderRadius.circular(4), ), child: Stack( children: [ // Thumbnail image or placeholder. AspectRatio( aspectRatio: 8.5 / 11, // US Letter-ish ratio child: ClipRRect( borderRadius: BorderRadius.circular(3), child: _cache.containsKey(index) ? Image(image: _cache[index]!, fit: BoxFit.cover) : Container( color: Theme.of( context, ).colorScheme.surfaceContainerLow, child: Center( child: Text( '${index + 1}', style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, color: Theme.of( context, ).colorScheme.onSurfaceVariant, ), ), ), ), ), ), // Page number overlay. Positioned( bottom: 2, right: 2, child: Container( padding: const EdgeInsets.symmetric( horizontal: 4, vertical: 1, ), decoration: BoxDecoration( color: Colors.black54, borderRadius: BorderRadius.circular(3), ), child: Text( '${index + 1}', style: const TextStyle( color: Colors.white, fontSize: 10, ), ), ), ), // Bookmark indicator. if (isBookmarked) Positioned( top: 2, left: 2, child: Container( width: 8, height: 8, decoration: BoxDecoration( color: Theme.of(context).colorScheme.primary, shape: BoxShape.circle, ), ), ), ], ), ), ); }, ), ); } }