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>
207 lines
6.6 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|