All checks were successful
CI / Windows build (push) Successful in 8m39s
Per the full-refactor plan (P0/P1/P2 modules, all pressure-independent): - engine/undo_stack: generic snapshot undo/redo (per page in the editor) - ui/thumbnail_grid: Drawboard-style lazy thumbnail nav sheet (pdfrx) - input/pen_config + ui/pen_settings_page: configurable side-button / eraser-end action mapping, pressure curve, palm sensitivity, finger drawing, widths (shared_preferences). Button-action mappings persist but consume in the input arbiter later; widths/finger consumed now. Wired into the live editor (undo/redo + grid + settings buttons). 19 new tests.
236 lines
7.6 KiB
Dart
236 lines
7.6 KiB
Dart
// 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<int> onPageSelected;
|
|
|
|
@override
|
|
State<PageThumbnailGrid> createState() => _PageThumbnailGridState();
|
|
}
|
|
|
|
class _PageThumbnailGridState extends State<PageThumbnailGrid> {
|
|
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<void> showPageThumbnailSheet(
|
|
BuildContext context, {
|
|
required PdfDocument document,
|
|
required int currentPage,
|
|
required ValueChanged<int> onPageSelected,
|
|
}) {
|
|
return showModalBottomSheet<void>(
|
|
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);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|