feat(editor): undo/redo, thumbnails, pen settings
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.
This commit is contained in:
2026-06-21 23:56:38 +08:00
parent 28b7af7e72
commit e4a94d00c0
7 changed files with 1206 additions and 7 deletions

View File

@@ -0,0 +1,309 @@
import 'package:flutter/material.dart';
import '../input/pen_config.dart';
/// Shows a Material You modal bottom sheet for configuring pen input.
///
/// Changes are applied and persisted immediately via [controller] setters,
/// so the sheet reflects the live configuration at all times.
Future<void> showPenSettingsSheet(
BuildContext context,
PenConfigController controller,
) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => _PenSettingsSheet(controller: controller),
);
}
class _PenSettingsSheet extends StatelessWidget {
const _PenSettingsSheet({required this.controller});
final PenConfigController controller;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
final config = controller.value;
final colorScheme = Theme.of(context).colorScheme;
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.7,
minChildSize: 0.4,
maxChildSize: 0.95,
builder: (context, scrollController) {
return ListView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
// Drag handle
Center(
child: Container(
width: 32,
height: 4,
margin: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withAlpha(80),
borderRadius: BorderRadius.circular(2),
),
),
),
Text(
'Pen Settings',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// ── Button Actions ────────────────────────────────────────
_SectionHeader(
title: 'Button Actions',
icon: Icons.touch_app,
colorScheme: colorScheme,
),
const SizedBox(height: 8),
_LabeledRow(
label: 'Side Button',
child: _ActionDropdown(
value: config.sideButton,
onChanged: controller.setSideButton,
),
),
const SizedBox(height: 8),
_LabeledRow(
label: 'Eraser End',
child: _ActionDropdown(
value: config.eraserEnd,
onChanged: controller.setEraserEnd,
),
),
const SizedBox(height: 16),
// ── Pressure ──────────────────────────────────────────────
_SectionHeader(
title: 'Pressure',
icon: Icons.compress,
colorScheme: colorScheme,
),
_SliderTile(
label: 'Pressure Gamma',
value: config.pressureGamma,
min: 0.3,
max: 3.0,
divisions: 27,
formatValue: (v) => v.toStringAsFixed(2),
onChanged: controller.setPressureGamma,
),
// ── Input ─────────────────────────────────────────────────
_SectionHeader(
title: 'Input',
icon: Icons.pan_tool_alt,
colorScheme: colorScheme,
),
_SliderTile(
label: 'Palm Rejection',
value: config.palmRejectionMs,
min: 0,
max: 500,
divisions: 50,
formatValue: (v) => '${v.round()} ms',
onChanged: controller.setPalmRejectionMs,
),
SwitchListTile(
title: const Text('Finger Drawing'),
subtitle: const Text(
'Allow touch strokes when no pen is detected',
),
value: config.fingerDrawing,
onChanged: controller.setFingerDrawing,
contentPadding: EdgeInsets.zero,
),
// ── Stroke Widths ─────────────────────────────────────────
_SectionHeader(
title: 'Stroke Widths',
icon: Icons.line_weight,
colorScheme: colorScheme,
),
_SliderTile(
label: 'Pen Width',
value: config.penWidth,
min: 0.001,
max: 0.02,
divisions: 19,
formatValue: (v) => v.toStringAsFixed(4),
onChanged: controller.setPenWidth,
),
_SliderTile(
label: 'Highlighter Width',
value: config.highlighterWidth,
min: 0.005,
max: 0.06,
divisions: 22,
formatValue: (v) => v.toStringAsFixed(4),
onChanged: controller.setHighlighterWidth,
),
],
);
},
);
},
);
}
}
// ── Shared section header ────────────────────────────────────────────────────
class _SectionHeader extends StatelessWidget {
const _SectionHeader({
required this.title,
required this.icon,
required this.colorScheme,
});
final String title;
final IconData icon;
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Row(
children: [
Icon(icon, size: 18, color: colorScheme.primary),
const SizedBox(width: 8),
Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
Expanded(child: Divider(color: colorScheme.outlineVariant)),
],
),
);
}
}
// ── Label + widget row ───────────────────────────────────────────────────────
class _LabeledRow extends StatelessWidget {
const _LabeledRow({required this.label, required this.child});
final String label;
final Widget child;
@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(
width: 120,
child: Text(
label,
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
Expanded(child: child),
],
);
}
}
// ── Action dropdown ──────────────────────────────────────────────────────────
class _ActionDropdown extends StatelessWidget {
const _ActionDropdown({required this.value, required this.onChanged});
final PenButtonAction value;
final ValueChanged<PenButtonAction> onChanged;
static String _label(PenButtonAction action) => switch (action) {
PenButtonAction.none => 'None',
PenButtonAction.eraser => 'Eraser',
PenButtonAction.undo => 'Undo',
PenButtonAction.toggleTool => 'Toggle Tool',
PenButtonAction.pan => 'Pan',
};
@override
Widget build(BuildContext context) {
return DropdownMenu<PenButtonAction>(
initialSelection: value,
expandedInsets: EdgeInsets.zero,
onSelected: (action) {
if (action != null) onChanged(action);
},
dropdownMenuEntries: PenButtonAction.values
.map(
(a) => DropdownMenuEntry(value: a, label: _label(a)),
)
.toList(),
);
}
}
// ── Slider with live value label ─────────────────────────────────────────────
class _SliderTile extends StatelessWidget {
const _SliderTile({
required this.label,
required this.value,
required this.min,
required this.max,
required this.divisions,
required this.formatValue,
required this.onChanged,
});
final String label;
final double value;
final double min;
final double max;
final int divisions;
final String Function(double) formatValue;
final ValueChanged<double> onChanged;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.w500)),
Text(
formatValue(value),
style: TextStyle(
fontFamily: 'monospace',
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 13,
),
),
],
),
Slider(
value: value.clamp(min, max),
min: min,
max: max,
divisions: divisions,
label: formatValue(value),
onChanged: onChanged,
),
],
);
}
}

View File

@@ -0,0 +1,235 @@
// 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);
},
),
),
],
);
},
);
},
);
}