Some checks failed
CI / Windows build (push) Has been cancelled
Wires simplifyStroke into the save conversion: a fast Surface-Pen stroke's hundreds of near-collinear samples are thinned before hitting the DB, shrinking the row + speeding reload re-rasterization (R10) with no perceptible change. The live in-memory strokes are untouched — only what we PERSIST is simplified. Makes the (unit-tested) RDP core load-bearing. flutter analyze lib/editor clean; 238/238 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
867 lines
30 KiB
Dart
867 lines
30 KiB
Dart
// lib/editor/canvas/pen_editor_screen.dart
|
|
//
|
|
// Page-based pen-first PDF editor. Opens a PDF with pdfrx's document API,
|
|
// shows ONE page at a time as a bitmap (PdfPageView — a per-page widget that
|
|
// renders to an image and does NOT capture pan/zoom gestures), overlaid by the
|
|
// ink layer. Both share one transform via PenCanvas. Prev/Next + jump-to-page.
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:pdfrx/pdfrx.dart';
|
|
|
|
import '../../services/database_service.dart';
|
|
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
|
import '../engine/stroke_model.dart';
|
|
import '../engine/stroke_simplify.dart';
|
|
import '../engine/undo_stack.dart';
|
|
import '../input/diagnostic_logger.dart';
|
|
import '../input/pen_config.dart';
|
|
import '../input/pen_input_service.dart';
|
|
import '../layout/viewport_fit.dart';
|
|
import '../persistence/editor_repository.dart';
|
|
import '../persistence/save_scheduler.dart';
|
|
import '../ui/pen_settings_page.dart';
|
|
import '../ui/thumbnail_grid.dart';
|
|
import 'input_diagnostics.dart';
|
|
import 'pen_canvas.dart';
|
|
import 'pen_stroke.dart';
|
|
|
|
/// Stable deterministic document-id for a file path (djb2 hash → hex).
|
|
///
|
|
/// Produces a fixed-length hex string from the path so the id is filesystem-
|
|
/// independent (no slashes, spaces, or non-ASCII characters) and stable across
|
|
/// restarts. Collisions are astronomically unlikely for a single-user app.
|
|
String _documentIdFromPath(String path) {
|
|
var hash = 5381;
|
|
for (final c in path.codeUnits) {
|
|
hash = ((hash << 5) + hash + c) & 0xFFFFFFFF;
|
|
}
|
|
return hash.toRadixString(16).padLeft(8, '0');
|
|
}
|
|
|
|
class PenEditorScreen extends StatefulWidget {
|
|
const PenEditorScreen({super.key, required this.pdfPath});
|
|
|
|
final String pdfPath;
|
|
|
|
@override
|
|
State<PenEditorScreen> createState() => _PenEditorScreenState();
|
|
}
|
|
|
|
class _PenEditorScreenState extends State<PenEditorScreen> {
|
|
PdfDocument? _document;
|
|
Object? _openError;
|
|
|
|
/// 0-based current page index.
|
|
int _pageIndex = 0;
|
|
|
|
/// Strokes per page, keyed by 0-based page index (normalized coords).
|
|
final Map<int, List<PenStroke>> _strokesByPage = {};
|
|
|
|
/// Per-page undo/redo history. Snapshot-before-change discipline: the
|
|
/// pre-mutation stroke list is recorded before each commit/erase.
|
|
final Map<int, UndoStack<List<PenStroke>>> _undo = {};
|
|
|
|
UndoStack<List<PenStroke>> _undoFor(int page) =>
|
|
_undo.putIfAbsent(page, () => UndoStack<List<PenStroke>>());
|
|
|
|
/// Pen input configuration (widths, finger drawing, button actions).
|
|
/// Loaded asynchronously in initState; null until ready.
|
|
///
|
|
/// NOTE: the side-button / eraser-end ACTION MAPPINGS (sideButton/eraserEnd)
|
|
/// are persisted via this controller but NOT yet consumed here — they wire
|
|
/// into the input arbiter in a later step. Only widths and fingerDrawing are
|
|
/// consumed for now.
|
|
PenConfigController? _penConfig;
|
|
|
|
// ── Persistence ────────────────────────────────────────────────────────────
|
|
|
|
/// Stable document-id derived from the PDF file path.
|
|
late final String _documentId;
|
|
|
|
SaveScheduler? _saveScheduler;
|
|
|
|
/// One shared transform for the current page; recentred on page change so
|
|
/// each page opens fit-to-view and centered.
|
|
final TransformationController _transform = TransformationController();
|
|
|
|
/// Set when the page must be (re)centered on the next layout pass.
|
|
bool _needsCenter = true;
|
|
|
|
/// Live page value while dragging the page slider (null when not dragging).
|
|
double? _scrub;
|
|
|
|
/// Whether the page-jump slider is expanded (NOT persistent — toggled by
|
|
/// tapping the page label; collapses after a jump).
|
|
bool _showSlider = false;
|
|
|
|
/// Latest pen-event debug readout (kind/pressure/min/max) — shown only when
|
|
/// the diagnostic toggle is on, to inspect what Windows delivers.
|
|
String _penDebug = '';
|
|
bool _showPenDebug = false;
|
|
|
|
// Tool state.
|
|
CanvasTool _tool = CanvasTool.pen;
|
|
Color _color = Colors.black;
|
|
bool _allowFingerDrawing = false;
|
|
|
|
/// Pen width as a fraction of page width (base; pressure thins it down).
|
|
static const double _penWidthFraction = 0.006;
|
|
static const double _highlighterWidthFraction = 0.02;
|
|
|
|
static const List<Color> _palette = [
|
|
Colors.black,
|
|
Colors.red,
|
|
Colors.blue,
|
|
Colors.green,
|
|
Colors.orange,
|
|
];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_documentId = _documentIdFromPath(widget.pdfPath);
|
|
// Begin listening to the native Windows pen plugin (barrel/eraser/tilt).
|
|
// No-op on platforms without the plugin (W3).
|
|
PenInputService.instance.start();
|
|
_initPersistence();
|
|
_initPenConfig();
|
|
_open();
|
|
}
|
|
|
|
Future<void> _initPenConfig() async {
|
|
final controller = await PenConfigController.load();
|
|
if (!mounted) {
|
|
controller.dispose();
|
|
return;
|
|
}
|
|
// Rebuild the editor when pen settings change (width, pressure
|
|
// sensitivity, button mappings) so the live canvas reflects them.
|
|
controller.addListener(_onPenConfigChanged);
|
|
setState(() {
|
|
_penConfig = controller;
|
|
// Adopt the persisted finger-drawing preference as the initial local
|
|
// toggle state. The local 🖐 toggle keeps working and stays in sync with
|
|
// the controller (see _toggleFingerDrawing).
|
|
_allowFingerDrawing = controller.value.fingerDrawing;
|
|
});
|
|
}
|
|
|
|
void _onPenConfigChanged() {
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
Future<void> _initPersistence() async {
|
|
final service = await DatabaseService.getInstance();
|
|
if (!mounted) return;
|
|
final repo = await EditorRepository.fromService(service);
|
|
final scheduler = SaveScheduler(repo);
|
|
if (!mounted) {
|
|
scheduler.dispose();
|
|
return;
|
|
}
|
|
_saveScheduler = scheduler;
|
|
// Load any previously persisted strokes for this document.
|
|
await _loadPersistedStrokes(repo);
|
|
}
|
|
|
|
/// Load all persisted strokes for [_documentId] and populate [_strokesByPage].
|
|
Future<void> _loadPersistedStrokes(EditorRepository repo) async {
|
|
final hosted = await repo.loadDocument(_documentId);
|
|
if (!mounted) return;
|
|
final loaded = <int, List<PenStroke>>{};
|
|
for (final entry in hosted.entries) {
|
|
final pageIndex = _pageIndexFromHostId(entry.key);
|
|
if (pageIndex == null) continue;
|
|
loaded[pageIndex] = entry.value
|
|
.map((es) => PenStroke(
|
|
points: es.points
|
|
.map((ep) => PenPoint(ep.x, ep.y, ep.pressure, tilt: ep.tilt))
|
|
.toList(),
|
|
color: es.color,
|
|
width: es.width,
|
|
kind: es.tool == EditorTool.highlighter
|
|
? PenStrokeKind.highlighter
|
|
: PenStrokeKind.pen,
|
|
))
|
|
.toList();
|
|
}
|
|
if (loaded.isNotEmpty) {
|
|
setState(() {
|
|
for (final entry in loaded.entries) {
|
|
_strokesByPage[entry.key] = entry.value;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Extract the page index from a host_id of the form
|
|
/// `"doc:<documentId>:page:<pageIndex>"`.
|
|
int? _pageIndexFromHostId(String hostId) {
|
|
const marker = ':page:';
|
|
final idx = hostId.lastIndexOf(marker);
|
|
if (idx == -1) return null;
|
|
return int.tryParse(hostId.substring(idx + marker.length));
|
|
}
|
|
|
|
Future<void> _open() async {
|
|
try {
|
|
final doc = await PdfDocument.openFile(widget.pdfPath);
|
|
if (!mounted) {
|
|
doc.dispose();
|
|
return;
|
|
}
|
|
setState(() => _document = doc);
|
|
} catch (e) {
|
|
if (mounted) setState(() => _openError = e);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
// Flush any pending scheduled saves before tearing down.
|
|
final scheduler = _saveScheduler;
|
|
if (scheduler != null) {
|
|
scheduler.flush(); // fire-and-forget; DB write continues in isolate
|
|
scheduler.dispose();
|
|
}
|
|
_document?.dispose();
|
|
_transform.dispose();
|
|
_penConfig?.dispose();
|
|
PenInputService.instance.stop();
|
|
DiagnosticLogger.instance.stop();
|
|
super.dispose();
|
|
}
|
|
|
|
List<PenStroke> get _currentStrokes =>
|
|
_strokesByPage.putIfAbsent(_pageIndex, () => <PenStroke>[]);
|
|
|
|
void _commitStroke(PenStroke stroke) {
|
|
// Snapshot-before-change: record the pre-mutation page state for undo.
|
|
_undoFor(_pageIndex).record(List<PenStroke>.of(_currentStrokes));
|
|
setState(() {
|
|
// Replace with a NEW list so StaticInkPainter sees a fresh identity and
|
|
// actually repaints (mutating in place would alias the old painter's list
|
|
// and shouldRepaint would see no change → committed strokes vanish).
|
|
_strokesByPage[_pageIndex] = [
|
|
...?_strokesByPage[_pageIndex],
|
|
stroke,
|
|
];
|
|
});
|
|
// Snapshot SYNCHRONOUSLY (before any await) then schedule persistence.
|
|
final snapshot = List<PenStroke>.of(_strokesByPage[_pageIndex]!);
|
|
_schedulePageSave(_pageIndex, snapshot);
|
|
}
|
|
|
|
/// Replace committed stroke [index] with its surviving pieces after a partial
|
|
/// (segment) erase. An empty [replacements] list removes the stroke entirely.
|
|
void _eraseStroke(int index, List<PenStroke> replacements) {
|
|
final list = _strokesByPage[_pageIndex];
|
|
final willMutate = list != null && index >= 0 && index < list.length;
|
|
if (willMutate) {
|
|
// Snapshot-before-change: record the pre-mutation page state for undo.
|
|
_undoFor(_pageIndex).record(List<PenStroke>.of(list));
|
|
}
|
|
setState(() {
|
|
if (list != null && index >= 0 && index < list.length) {
|
|
final next = List<PenStroke>.of(list)
|
|
..replaceRange(index, index + 1, replacements);
|
|
_strokesByPage[_pageIndex] = next;
|
|
}
|
|
});
|
|
// Snapshot SYNCHRONOUSLY after the mutation, then schedule persistence.
|
|
final current = _strokesByPage[_pageIndex];
|
|
final snapshot =
|
|
current != null ? List<PenStroke>.of(current) : <PenStroke>[];
|
|
_schedulePageSave(_pageIndex, snapshot);
|
|
}
|
|
|
|
/// Convert [strokes] to [EditorStroke]s and hand them to the save scheduler.
|
|
///
|
|
/// Must be called synchronously (no await between the snapshot and this call)
|
|
/// so the scheduler receives an immutable copy of the in-memory state.
|
|
void _schedulePageSave(int pageIndex, List<PenStroke> strokes) {
|
|
final scheduler = _saveScheduler;
|
|
if (scheduler == null) return;
|
|
// Compact strokes (RDP) before persisting: a fast Surface-Pen stroke lands
|
|
// hundreds of near-collinear samples; thinning them shrinks the DB row +
|
|
// speeds reload re-rasterization (R10) with no perceptible change. The live
|
|
// in-memory strokes are untouched — only what we PERSIST is simplified.
|
|
final editorStrokes = strokes
|
|
.map((s) => simplifyStroke(EditorStroke.fromPenStroke(s)))
|
|
.toList();
|
|
scheduler.schedule(
|
|
'page',
|
|
EditorRepository.pageHostId(_documentId, pageIndex),
|
|
editorStrokes,
|
|
);
|
|
}
|
|
|
|
void _goToPage(int index) {
|
|
final doc = _document;
|
|
if (doc == null) return;
|
|
final clamped = index.clamp(0, doc.pages.length - 1);
|
|
if (clamped == _pageIndex) return;
|
|
setState(() {
|
|
_pageIndex = clamped;
|
|
_needsCenter = true; // recenter the new page on next layout
|
|
});
|
|
}
|
|
|
|
/// Undo the last draw/erase on the current page, restoring and persisting
|
|
/// the previous snapshot.
|
|
void _performUndo() {
|
|
final stack = _undoFor(_pageIndex);
|
|
if (!stack.canUndo) return;
|
|
final current = List<PenStroke>.of(_currentStrokes);
|
|
final snapshot = stack.undo(current);
|
|
if (snapshot == null) return;
|
|
setState(() {
|
|
// New list identity so StaticInkPainter repaints.
|
|
_strokesByPage[_pageIndex] = List<PenStroke>.of(snapshot);
|
|
});
|
|
_schedulePageSave(_pageIndex, List<PenStroke>.of(snapshot));
|
|
}
|
|
|
|
/// Redo the last undone draw/erase on the current page.
|
|
void _performRedo() {
|
|
final stack = _undoFor(_pageIndex);
|
|
if (!stack.canRedo) return;
|
|
final snapshot = stack.redo();
|
|
if (snapshot == null) return;
|
|
setState(() {
|
|
_strokesByPage[_pageIndex] = List<PenStroke>.of(snapshot);
|
|
});
|
|
_schedulePageSave(_pageIndex, List<PenStroke>.of(snapshot));
|
|
}
|
|
|
|
/// Cycle pen → highlighter → eraser → pen (for the toggleTool button action).
|
|
void _cycleTool() {
|
|
setState(() {
|
|
_tool = switch (_tool) {
|
|
CanvasTool.pen => CanvasTool.highlighter,
|
|
CanvasTool.highlighter => CanvasTool.eraser,
|
|
CanvasTool.eraser => CanvasTool.pen,
|
|
};
|
|
});
|
|
}
|
|
|
|
/// Handle a hardware pen-button action delivered by [PenCanvas] (W3).
|
|
/// `eraser` and `pan` are handled inside the canvas; here we map the
|
|
/// edge-triggered ones.
|
|
void _handlePenButtonAction(PenButtonAction action) {
|
|
switch (action) {
|
|
case PenButtonAction.undo:
|
|
_performUndo();
|
|
case PenButtonAction.toggleTool:
|
|
_cycleTool();
|
|
case PenButtonAction.eraser:
|
|
case PenButtonAction.pan:
|
|
case PenButtonAction.none:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// Toggle finger-drawing, keeping the local state and the persisted config
|
|
/// (when loaded) in sync.
|
|
void _toggleFingerDrawing() {
|
|
final next = !_allowFingerDrawing;
|
|
setState(() => _allowFingerDrawing = next);
|
|
_penConfig?.setFingerDrawing(next);
|
|
}
|
|
|
|
/// Open the page thumbnail grid; tapping a thumbnail navigates to that page.
|
|
void _openThumbnails() {
|
|
final doc = _document;
|
|
if (doc == null) return;
|
|
showPageThumbnailSheet(
|
|
context,
|
|
document: doc,
|
|
currentPage: _pageIndex,
|
|
onPageSelected: _goToPage,
|
|
);
|
|
}
|
|
|
|
/// Open the pen settings sheet (widths, pressure, finger drawing, etc.).
|
|
void _openPenSettings() {
|
|
final config = _penConfig;
|
|
if (config == null) return;
|
|
showPenSettingsSheet(context, config);
|
|
}
|
|
|
|
/// Centre [pageSize] within [viewport] via the shared transform. Uses the
|
|
/// shared, unit-tested [centerOffset] (pageSize is already fit to the viewport
|
|
/// at scale 1, so we center at scale 1).
|
|
void _centerPage(Size viewport, Size pageSize) {
|
|
final o = centerOffset(pageSize, viewport, 1.0);
|
|
_transform.value = Matrix4.identity()..setTranslationRaw(o.dx, o.dy, 0);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
body: Stack(
|
|
children: [
|
|
Positioned.fill(child: _buildBody()),
|
|
// Floating Material You tool palette (top-center).
|
|
SafeArea(
|
|
child: Align(
|
|
alignment: Alignment.topCenter,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: _buildToolPalette(),
|
|
),
|
|
),
|
|
),
|
|
// Floating page-control pill (bottom-center).
|
|
if (_document != null)
|
|
SafeArea(
|
|
child: Align(
|
|
alignment: Alignment.bottomCenter,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(bottom: 16),
|
|
child: _buildPagePill(),
|
|
),
|
|
),
|
|
),
|
|
// Back button (top-left).
|
|
SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(8),
|
|
child: _RoundIconButton(
|
|
icon: Icons.arrow_back,
|
|
tooltip: 'Back',
|
|
onPressed: () => Navigator.of(context).maybePop(),
|
|
),
|
|
),
|
|
),
|
|
// Pen diagnostic readout (top-right) — shows what Windows delivers.
|
|
if (_showPenDebug)
|
|
SafeArea(
|
|
child: Align(
|
|
alignment: Alignment.topRight,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(8),
|
|
child: Material(
|
|
color: Theme.of(context).colorScheme.inverseSurface,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 380),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10, vertical: 6),
|
|
child: ListenableBuilder(
|
|
listenable: InputDiagnostics.instance,
|
|
builder: (context, _) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
final d = InputDiagnostics.instance;
|
|
final tail = d.trace.length > 6
|
|
? d.trace.sublist(d.trace.length - 6)
|
|
: d.trace;
|
|
final mono = TextStyle(
|
|
fontFamily: 'monospace',
|
|
fontSize: 11,
|
|
color: cs.onInverseSurface);
|
|
final monoFaint = mono.copyWith(
|
|
fontSize: 10,
|
|
color:
|
|
cs.onInverseSurface.withValues(alpha: 0.75));
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
_penDebug.isEmpty
|
|
? 'hover / draw with the pen…'
|
|
: _penDebug,
|
|
style: mono),
|
|
const SizedBox(height: 4),
|
|
Text(d.summary(), style: mono),
|
|
if (tail.isNotEmpty) ...[
|
|
const SizedBox(height: 4),
|
|
Text(tail.join('\n'), style: monoFaint),
|
|
],
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'log: ${DiagnosticLogger.instance.path ?? "(developer.log only)"}',
|
|
style: monoFaint),
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: TextButton(
|
|
onPressed: () =>
|
|
InputDiagnostics.instance.reset(),
|
|
child: Text('Reset stats',
|
|
style:
|
|
TextStyle(color: cs.inversePrimary)),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildBody() {
|
|
if (_openError != null) {
|
|
return Center(child: Text('Failed to open PDF:\n$_openError'));
|
|
}
|
|
final doc = _document;
|
|
if (doc == null) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
if (doc.pages.isEmpty) {
|
|
return const Center(child: Text('PDF has no pages.'));
|
|
}
|
|
|
|
final page = doc.pages[_pageIndex];
|
|
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
// Fit the page rectangle into the available viewport at scale 1.0; the
|
|
// InteractiveViewer then zooms/pans from there. Ink normalized coords
|
|
// map onto this rectangle.
|
|
final fit = (constraints.maxWidth / page.width)
|
|
.clamp(0.0, double.infinity);
|
|
final fitH = constraints.maxHeight / page.height;
|
|
final scale = fit < fitH ? fit : fitH;
|
|
final pageSize = Size(page.width * scale, page.height * scale);
|
|
|
|
if (_needsCenter) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
_centerPage(
|
|
Size(constraints.maxWidth, constraints.maxHeight), pageSize);
|
|
setState(() => _needsCenter = false);
|
|
});
|
|
}
|
|
return PenCanvas(
|
|
key: ValueKey(_pageIndex),
|
|
pageSize: pageSize,
|
|
strokes: _currentStrokes,
|
|
transformationController: _transform,
|
|
tool: _tool,
|
|
color: _color,
|
|
strokeWidth: _tool == CanvasTool.highlighter
|
|
? (_penConfig?.value.highlighterWidth ??
|
|
_highlighterWidthFraction)
|
|
: (_penConfig?.value.penWidth ?? _penWidthFraction),
|
|
thinning:
|
|
_penConfig?.value.pressureSensitivity ?? kDefaultPenThinning,
|
|
sideButtonAction:
|
|
_penConfig?.value.sideButton ?? PenButtonAction.eraser,
|
|
eraserEndAction:
|
|
_penConfig?.value.eraserEnd ?? PenButtonAction.eraser,
|
|
onPenButtonAction: _handlePenButtonAction,
|
|
allowFingerDrawing: _allowFingerDrawing,
|
|
onPenDebug: _showPenDebug
|
|
? (s) => setState(() => _penDebug = s)
|
|
: null,
|
|
onStrokeComplete: _commitStroke,
|
|
onEraseStroke: _eraseStroke,
|
|
pageWidget: PdfPageView(
|
|
document: doc,
|
|
pageNumber: _pageIndex + 1,
|
|
// Fill the SizedBox exactly so ink aligns to the page rect (no
|
|
// internal letterboxing offset).
|
|
pageSizeCallback: (biggest, page, rotation) => biggest,
|
|
decoration: const BoxDecoration(color: Colors.white),
|
|
backgroundColor: Colors.white,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Floating Material You tool palette: a tonal rounded surface holding the
|
|
/// tools, color dots, and finger-drawing toggle.
|
|
Widget _buildToolPalette() {
|
|
final cs = Theme.of(context).colorScheme;
|
|
return Material(
|
|
color: cs.surfaceContainerHigh,
|
|
elevation: 3,
|
|
borderRadius: BorderRadius.circular(28),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_ToolButton(
|
|
icon: Icons.edit_outlined,
|
|
selected: _tool == CanvasTool.pen,
|
|
tooltip: 'Pen',
|
|
onPressed: () => setState(() => _tool = CanvasTool.pen),
|
|
),
|
|
_ToolButton(
|
|
icon: Icons.brush_outlined,
|
|
selected: _tool == CanvasTool.highlighter,
|
|
tooltip: 'Highlighter',
|
|
onPressed: () => setState(() => _tool = CanvasTool.highlighter),
|
|
),
|
|
_ToolButton(
|
|
icon: Icons.cleaning_services_outlined,
|
|
selected: _tool == CanvasTool.eraser,
|
|
tooltip: 'Eraser',
|
|
onPressed: () => setState(() => _tool = CanvasTool.eraser),
|
|
),
|
|
_Divider(cs: cs),
|
|
// Undo / redo (per page).
|
|
_ToolButton(
|
|
icon: Icons.undo,
|
|
selected: false,
|
|
tooltip: 'Undo',
|
|
onPressed: _undoFor(_pageIndex).canUndo ? _performUndo : null,
|
|
),
|
|
_ToolButton(
|
|
icon: Icons.redo,
|
|
selected: false,
|
|
tooltip: 'Redo',
|
|
onPressed: _undoFor(_pageIndex).canRedo ? _performRedo : null,
|
|
),
|
|
_Divider(cs: cs),
|
|
for (final c in _palette) _colorDot(c, cs),
|
|
_Divider(cs: cs),
|
|
_ToolButton(
|
|
icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
|
|
selected: _allowFingerDrawing,
|
|
tooltip: _allowFingerDrawing
|
|
? 'Finger drawing ON'
|
|
: 'Finger drawing OFF (pen only)',
|
|
onPressed: _toggleFingerDrawing,
|
|
),
|
|
// Page thumbnail grid.
|
|
_ToolButton(
|
|
icon: Icons.grid_view,
|
|
selected: false,
|
|
tooltip: 'Pages',
|
|
onPressed: _document != null ? _openThumbnails : null,
|
|
),
|
|
// Pen settings.
|
|
_ToolButton(
|
|
icon: Icons.settings_outlined,
|
|
selected: false,
|
|
tooltip: 'Pen settings',
|
|
onPressed: _penConfig != null ? _openPenSettings : null,
|
|
),
|
|
_ToolButton(
|
|
icon: Icons.bug_report_outlined,
|
|
selected: _showPenDebug,
|
|
tooltip: 'Input diagnostic (writes a log file)',
|
|
onPressed: () {
|
|
final on = !_showPenDebug;
|
|
setState(() => _showPenDebug = on);
|
|
if (on) {
|
|
InputDiagnostics.instance.reset();
|
|
DiagnosticLogger.instance.start();
|
|
} else {
|
|
DiagnosticLogger.instance.stop();
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _colorDot(Color c, ColorScheme cs) {
|
|
final selected = _color == c;
|
|
return GestureDetector(
|
|
onTap: () => setState(() => _color = c),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 150),
|
|
width: 28,
|
|
height: 28,
|
|
margin: const EdgeInsets.symmetric(horizontal: 3),
|
|
decoration: BoxDecoration(
|
|
color: c,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: selected ? cs.primary : cs.outlineVariant,
|
|
width: selected ? 3 : 1.5,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Floating page control: a COMPACT pill (prev / "n / total" / next). Tapping
|
|
/// the label reveals a drag-slider — which is NOT persistent (collapses again
|
|
/// on tap) so it doesn't block the page. No keyboard input (Windows IME is
|
|
/// unreliable).
|
|
Widget _buildPagePill() {
|
|
final doc = _document!;
|
|
final cs = Theme.of(context).colorScheme;
|
|
final total = doc.pages.length;
|
|
final shown = (_scrub ?? (_pageIndex + 1).toDouble()).round();
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Slider — shown only when expanded (not persistent).
|
|
if (_showSlider && total > 1)
|
|
Container(
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
constraints: const BoxConstraints(maxWidth: 420),
|
|
child: Material(
|
|
color: cs.surfaceContainerHigh,
|
|
elevation: 3,
|
|
borderRadius: BorderRadius.circular(28),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
child: Slider(
|
|
min: 1,
|
|
max: total.toDouble(),
|
|
value: (_scrub ?? (_pageIndex + 1).toDouble())
|
|
.clamp(1, total.toDouble()),
|
|
label: '$shown',
|
|
divisions: total - 1,
|
|
onChanged: (v) => setState(() => _scrub = v),
|
|
onChangeEnd: (v) {
|
|
setState(() => _scrub = null);
|
|
_goToPage(v.round() - 1);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// Compact pill — always; fits content (no big frame).
|
|
Material(
|
|
color: cs.surfaceContainerHigh,
|
|
elevation: 3,
|
|
borderRadius: BorderRadius.circular(28),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
tooltip: 'Previous page',
|
|
icon: const Icon(Icons.chevron_left),
|
|
onPressed:
|
|
_pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null,
|
|
),
|
|
TextButton(
|
|
onPressed: total > 1
|
|
? () => setState(() => _showSlider = !_showSlider)
|
|
: null,
|
|
child: Text(
|
|
'$shown / $total',
|
|
style: TextStyle(
|
|
color: cs.onSurface,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: 'Next page',
|
|
icon: const Icon(Icons.chevron_right),
|
|
onPressed: _pageIndex < total - 1
|
|
? () => _goToPage(_pageIndex + 1)
|
|
: null,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A Material You toggle-style icon button for the tool palette.
|
|
class _ToolButton extends StatelessWidget {
|
|
const _ToolButton({
|
|
required this.icon,
|
|
required this.selected,
|
|
required this.tooltip,
|
|
required this.onPressed,
|
|
});
|
|
|
|
final IconData icon;
|
|
final bool selected;
|
|
final String tooltip;
|
|
|
|
/// Tap handler. When null the button renders disabled (dimmed, no ripple).
|
|
final VoidCallback? onPressed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
final enabled = onPressed != null;
|
|
final iconColor = !enabled
|
|
? cs.onSurfaceVariant.withValues(alpha: 0.38)
|
|
: selected
|
|
? cs.onSecondaryContainer
|
|
: cs.onSurfaceVariant;
|
|
return Tooltip(
|
|
message: tooltip,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(20),
|
|
onTap: onPressed,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 150),
|
|
margin: const EdgeInsets.symmetric(horizontal: 2),
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: selected ? cs.secondaryContainer : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Icon(
|
|
icon,
|
|
size: 22,
|
|
color: iconColor,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Divider extends StatelessWidget {
|
|
const _Divider({required this.cs});
|
|
final ColorScheme cs;
|
|
@override
|
|
Widget build(BuildContext context) => Container(
|
|
width: 1,
|
|
height: 24,
|
|
margin: const EdgeInsets.symmetric(horizontal: 6),
|
|
color: cs.outlineVariant,
|
|
);
|
|
}
|
|
|
|
/// A round, tonal icon button (used for the floating back button).
|
|
class _RoundIconButton extends StatelessWidget {
|
|
const _RoundIconButton({
|
|
required this.icon,
|
|
required this.tooltip,
|
|
required this.onPressed,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String tooltip;
|
|
final VoidCallback onPressed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cs = Theme.of(context).colorScheme;
|
|
return Material(
|
|
color: cs.surfaceContainerHigh,
|
|
elevation: 3,
|
|
shape: const CircleBorder(),
|
|
child: IconButton(
|
|
tooltip: tooltip,
|
|
icon: Icon(icon),
|
|
color: cs.onSurfaceVariant,
|
|
onPressed: onPressed,
|
|
),
|
|
);
|
|
}
|
|
}
|