Files
BadNote/lib/widgets/page_thumbnail_sidebar.dart
Akiba So 72428dc075
Some checks failed
CI / Test (Server, optional) (push) Failing after 2m10s
Windows Build / Build Windows (x64) (push) Failing after 29s
CI / Test (Flutter, Linux) (push) Has been cancelled
CI / Analyze (Flutter) (push) Has been cancelled
Fix bugs across app + server, optimize UI/UX, add Gitea CI
Bug fixes (Flutter):
- Wrap multi-statement DB writes (insert/update/delete note, deleteDocument,
  deletePageData, OCR FTS merge, migrations) in transactions to prevent data
  loss on interruption and a read-modify-write FTS race.
- Fix PdfDocument leaks on exception (try/finally dispose) and preserve image
  aspect ratio when stamping images onto PDF pages.
- Guard file-picker against empty selection (was .single -> crash).
- Fix eraser ConcurrentModificationError and unmodifiable-list crash on PDF
  pages; capture page synchronously on save to stop wrong-page data loss.
- Fix Riverpod DB-not-ready races, broken pull-to-refresh, settings load race,
  and search N+1; transform stored annotations on PDF page rotation.
- Normalize pen pressure for devices without a pressure range.
- PPT: single source of truth for slide strokes so ink displays and exports.

UI/UX:
- Material 3 typography, theme-aware colors (dark-mode fixes), hover cursors
  and right-click/visible actions on desktop, keyboard shortcuts (undo/redo/
  save/find), toolbar overflow handling, friendlier empty states, semantic OCR
  status badges, relative timestamps, 1-based page indicators, large-deck PPT
  navigation, and a scratchpad-scope label in split view.

Server (optional backend):
- Persist JWT secret (was per-process random), block path traversal in storage,
  fix CORS '*'+credentials, add OCR job ownership checks, last-writer-wins sync
  guard, constant-time login, and split out heavy OCR deps so the API/tests run
  without them.

CI: Gitea workflows for format+analyze+test (Linux, system sqlite) and a
Windows release build; pristine `flutter analyze`, all Flutter and server tests
green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 03:18:00 +08:00

207 lines
6.6 KiB
Dart

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<int> onPageTap;
final Set<int> 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<PageThumbnailSidebar> createState() => _PageThumbnailSidebarState();
}
class _PageThumbnailSidebarState extends State<PageThumbnailSidebar> {
/// Cached thumbnail image data keyed by page index.
final Map<int, ImageProvider> _cache = {};
/// Pages currently being generated (to avoid duplicate work).
final Set<int> _loading = {};
/// Pages that permanently failed thumbnail generation (null result or throw).
/// Skipped on subsequent rebuilds to avoid a retry storm.
final Set<int> _failed = {};
@override
void didUpdateWidget(PageThumbnailSidebar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.documentId != widget.documentId) {
_cache.clear();
_loading.clear();
_failed.clear();
}
}
Future<void> _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,
),
),
),
],
),
),
);
},
),
);
}
}