The search-result document jump still opened the OLD PdfAnnotatorScreen, the last live entry to it. Route it to PenEditorScreen instead, and add an initialPage param to the editor so the jump lands on the hit's page (clamped to the document range once it loads). With this, PenEditorScreen is the ONLY reachable PDF surface; the old annotator is now dead code (no remaining references). flutter analyze: 0 issues. Full suite: 258/258.
895 lines
32 KiB
Dart
895 lines
32 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 '../../l10n/app_localizations.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 '../input/pressure_curve.dart' show kNaturalPressureGamma;
|
|
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,
|
|
this.initialPage = 0,
|
|
});
|
|
|
|
final String pdfPath;
|
|
|
|
/// 0-based page to open on (e.g. a search-result jump). Clamped to the
|
|
/// document's page range once it loads.
|
|
final int initialPage;
|
|
|
|
@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;
|
|
// Honor a requested initial page (search-result jump), clamped.
|
|
if (doc.pages.isNotEmpty) {
|
|
_pageIndex = widget.initialPage.clamp(0, doc.pages.length - 1);
|
|
}
|
|
});
|
|
} 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) {
|
|
final l = AppLocalizations.of(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: l.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() {
|
|
final l = AppLocalizations.of(context);
|
|
if (_openError != null) {
|
|
return Center(child: Text(l.failedToOpenPdf('$_openError')));
|
|
}
|
|
final doc = _document;
|
|
if (doc == null) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
if (doc.pages.isEmpty) {
|
|
return Center(child: Text(l.pdfNoPages));
|
|
}
|
|
|
|
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,
|
|
// Pressure-response shaping (the rnote-like feel). The pen-settings
|
|
// gamma slider now actually drives stroke width; fall back to the
|
|
// natural default when no config is loaded yet.
|
|
pressureGamma:
|
|
_penConfig?.value.pressureGamma ?? kNaturalPressureGamma,
|
|
eraserRadius:
|
|
_penConfig?.value.eraserRadius ?? kDefaultEraserRadius,
|
|
eraserWholeStroke: _penConfig?.value.eraserWholeStroke ?? false,
|
|
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;
|
|
final l = AppLocalizations.of(context);
|
|
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: l.toolPen,
|
|
onPressed: () => setState(() => _tool = CanvasTool.pen),
|
|
),
|
|
_ToolButton(
|
|
icon: Icons.brush_outlined,
|
|
selected: _tool == CanvasTool.highlighter,
|
|
tooltip: l.toolHighlighter,
|
|
onPressed: () => setState(() => _tool = CanvasTool.highlighter),
|
|
),
|
|
_ToolButton(
|
|
icon: Icons.cleaning_services_outlined,
|
|
selected: _tool == CanvasTool.eraser,
|
|
tooltip: l.toolEraser,
|
|
onPressed: () => setState(() => _tool = CanvasTool.eraser),
|
|
),
|
|
_Divider(cs: cs),
|
|
// Undo / redo (per page).
|
|
_ToolButton(
|
|
icon: Icons.undo,
|
|
selected: false,
|
|
tooltip: l.actionUndo,
|
|
onPressed: _undoFor(_pageIndex).canUndo ? _performUndo : null,
|
|
),
|
|
_ToolButton(
|
|
icon: Icons.redo,
|
|
selected: false,
|
|
tooltip: l.actionRedo,
|
|
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
|
|
? l.fingerDrawingOn
|
|
: l.fingerDrawingOff,
|
|
onPressed: _toggleFingerDrawing,
|
|
),
|
|
// Page thumbnail grid.
|
|
_ToolButton(
|
|
icon: Icons.grid_view,
|
|
selected: false,
|
|
tooltip: l.pages,
|
|
onPressed: _document != null ? _openThumbnails : null,
|
|
),
|
|
// Pen settings.
|
|
_ToolButton(
|
|
icon: Icons.settings_outlined,
|
|
selected: false,
|
|
tooltip: l.penSettings,
|
|
onPressed: _penConfig != null ? _openPenSettings : null,
|
|
),
|
|
_ToolButton(
|
|
icon: Icons.bug_report_outlined,
|
|
selected: _showPenDebug,
|
|
tooltip: l.inputDiagnostic,
|
|
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 l = AppLocalizations.of(context);
|
|
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: l.previousPage,
|
|
icon: const Icon(Icons.chevron_left),
|
|
onPressed:
|
|
_pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null,
|
|
),
|
|
TextButton(
|
|
onPressed: total > 1
|
|
? () => setState(() => _showSlider = !_showSlider)
|
|
: null,
|
|
child: Text(
|
|
l.pageOfPages(shown, total),
|
|
style: TextStyle(
|
|
color: cs.onSurface,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: l.nextPage,
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
}
|