feat(slide): rebuild PPT annotator on the pen-first canvas
All checks were successful
CI / Windows build (push) Successful in 14m34s

PPT slides now annotate with the single performant inking engine
(PenCanvas) instead of the old ink_canvas, per "all note features on the
pen-first canvas".

- PenSlideScreen: per-slide normalized strokes over each slide image,
  prev/next + slider nav, undo/redo, shared M3 palette, and the pressure
  curve / eraser size+mode / palm rejection from the shared canvas.
- slide_export: pure, tested export geometry. Because strokes are now
  normalized to the page rect, the PDF exporter maps them straight into
  each slide's draw rect — fixing the old exporter's known ink
  misalignment (it guessed live-widget size).
- Route PPT import + open -> PenSlideScreen; delete the dead old
  ppt_annotator_screen. (ink_canvas/annotation_toolbar remain for
  split_view, the last old-canvas screen.)

Tests: slide_export geometry (4). flutter analyze: 0 issues. Suite: 269/269.
This commit is contained in:
2026-06-23 10:27:09 +08:00
parent dfe5f2a477
commit ffb9e35755
5 changed files with 630 additions and 533 deletions

View File

@@ -0,0 +1,544 @@
// lib/editor/canvas/pen_slide_screen.dart
//
// Pen-first slide (PPT) annotator. Reuses the single performant inking engine
// (PenCanvas) over each slide image, with per-slide normalized strokes and
// prev/next navigation. Export to PDF maps the normalized strokes into each
// slide's draw rect (slide_export.dart) — which also fixes the old exporter's
// known ink-misalignment bug. In-memory only (PPT ink is not auto-saved; Export
// to PDF is how annotations are kept), matching the previous behavior.
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import 'package:syncfusion_flutter_pdf/pdf.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 '../pdf/slide_export.dart';
import 'pen_canvas.dart';
import 'pen_palette_widgets.dart';
import 'pen_stroke.dart';
class PenSlideScreen extends StatefulWidget {
const PenSlideScreen({
super.key,
required this.filePath,
required this.slideImagePaths,
this.extractedText,
});
final String filePath;
final List<String> slideImagePaths;
final String? extractedText;
@override
State<PenSlideScreen> createState() => _PenSlideScreenState();
}
class _PenSlideScreenState extends State<PenSlideScreen> {
int _slideIndex = 0;
final Map<int, List<PenStroke>> _strokesBySlide = {};
final Map<int, List<List<PenStroke>>> _undo = {};
final Map<int, List<List<PenStroke>>> _redo = {};
/// Intrinsic pixel size of each slide image, loaded async so the page rect
/// keeps the slide's aspect (no distortion). Null until loaded.
Map<int, Size>? _slideSizes;
CanvasTool _tool = CanvasTool.pen;
Color _color = Colors.black;
bool _allowFingerDrawing = false;
bool _needsCenter = true;
bool _showSlider = false;
double? _scrub;
PenConfigController? _penConfig;
final TransformationController _transform = TransformationController();
static const double _penWidthFraction = 0.006;
static const double _highlighterWidthFraction = 0.02;
static const Size _fallbackSlide = Size(1600, 900);
static const List<Color> _palette = [
Colors.black,
Colors.red,
Colors.blue,
Colors.green,
Colors.orange,
];
int get _slideCount => widget.slideImagePaths.length;
List<PenStroke> get _currentStrokes => _strokesBySlide[_slideIndex] ?? const [];
@override
void initState() {
super.initState();
PenInputService.instance.start();
_loadSlideSizes();
_initPenConfig();
}
Future<void> _loadSlideSizes() async {
final sizes = <int, Size>{};
for (var i = 0; i < _slideCount; i++) {
try {
final bytes = await File(widget.slideImagePaths[i]).readAsBytes();
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
sizes[i] = Size(
frame.image.width.toDouble(), frame.image.height.toDouble());
frame.image.dispose();
} catch (_) {
sizes[i] = _fallbackSlide;
}
}
if (mounted) setState(() => _slideSizes = sizes);
}
Future<void> _initPenConfig() async {
final controller = await PenConfigController.load();
if (!mounted) {
controller.dispose();
return;
}
controller.addListener(_onPenConfigChanged);
setState(() {
_penConfig = controller;
_allowFingerDrawing = controller.value.fingerDrawing;
});
}
void _onPenConfigChanged() {
if (mounted) setState(() {});
}
@override
void dispose() {
_penConfig?.removeListener(_onPenConfigChanged);
_penConfig?.dispose();
_transform.dispose();
super.dispose();
}
// ── Mutations ──────────────────────────────────────────────────────────────
void _pushUndo() {
(_undo[_slideIndex] ??= []).add(List<PenStroke>.from(_currentStrokes));
_redo[_slideIndex]?.clear();
}
void _commitStroke(PenStroke stroke) {
setState(() {
_pushUndo();
_strokesBySlide[_slideIndex] = [..._currentStrokes, stroke];
});
}
void _eraseStroke(int index, List<PenStroke> replacements) {
final strokes = _currentStrokes;
if (index < 0 || index >= strokes.length) return;
setState(() {
_pushUndo();
_strokesBySlide[_slideIndex] = [
...strokes.sublist(0, index),
...replacements,
...strokes.sublist(index + 1),
];
});
}
void _performUndo() {
final stack = _undo[_slideIndex];
if (stack == null || stack.isEmpty) return;
setState(() {
(_redo[_slideIndex] ??= []).add(List<PenStroke>.from(_currentStrokes));
_strokesBySlide[_slideIndex] = stack.removeLast();
});
}
void _performRedo() {
final stack = _redo[_slideIndex];
if (stack == null || stack.isEmpty) return;
setState(() {
(_undo[_slideIndex] ??= []).add(List<PenStroke>.from(_currentStrokes));
_strokesBySlide[_slideIndex] = stack.removeLast();
});
}
void _toggleFingerDrawing() {
final next = !_allowFingerDrawing;
setState(() => _allowFingerDrawing = next);
_penConfig?.setFingerDrawing(next);
}
void _goToSlide(int i) {
final clamped = i.clamp(0, _slideCount - 1);
if (clamped == _slideIndex) return;
setState(() {
_slideIndex = clamped;
_needsCenter = true;
});
}
// ── Export ──────────────────────────────────────────────────────────────────
Future<void> _exportPdf() async {
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
const SnackBar(content: Text('Exporting PDF...')));
try {
final bytes = await _buildPdfBytes();
final dir = await _exportDir();
final base = p.basenameWithoutExtension(widget.filePath);
final outPath = p.join(dir.path, '${base}_annotated.pdf');
await File(outPath).writeAsBytes(bytes);
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text('PDF saved: $outPath')));
} catch (e) {
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text('Export failed: $e')));
}
}
Future<Directory> _exportDir() async {
try {
final home = Platform.environment['HOME'];
if (home != null) {
final dir = Directory(p.join(home, 'Documents', 'BadNote'));
if (!await dir.exists()) await dir.create(recursive: true);
return dir;
}
} catch (_) {}
return Directory.current;
}
Future<Uint8List> _buildPdfBytes() async {
final doc = PdfDocument();
doc.pageSettings.margins.all = 0;
final sizes = _slideSizes ?? const {};
for (var i = 0; i < _slideCount; i++) {
final page = doc.pages.add();
final pageSize = page.getClientSize();
try {
final imgBytes = await File(widget.slideImagePaths[i]).readAsBytes();
final bitmap = PdfBitmap(imgBytes);
final imageSize = sizes[i] ??
Size(bitmap.width.toDouble(), bitmap.height.toDouble());
final draw = slideDrawRect(
Size(pageSize.width, pageSize.height), imageSize);
page.graphics.drawImage(bitmap, draw);
for (final stroke in _strokesBySlide[i] ?? const <PenStroke>[]) {
if (stroke.points.length < 2) continue;
final r = (stroke.color >> 16) & 0xFF;
final g = (stroke.color >> 8) & 0xFF;
final b = stroke.color & 0xFF;
final path = PdfPath();
path.startFigure();
for (var j = 0; j < stroke.points.length - 1; j++) {
final p1 = stroke.points[j];
final p2 = stroke.points[j + 1];
path.addLine(
normToSlide(p1.x, p1.y, draw),
normToSlide(p2.x, p2.y, draw),
);
}
page.graphics.drawPath(
path,
pen: PdfPen(PdfColor(r, g, b),
width: slideStrokeWidth(stroke.width, draw)),
);
}
} catch (_) {
page.graphics.drawRectangle(
brush: PdfSolidBrush(PdfColor(230, 230, 230)),
bounds: Rect.fromLTWH(0, 0, pageSize.width, pageSize.height),
);
}
}
final bytes = await doc.save();
doc.dispose();
return Uint8List.fromList(bytes);
}
// ── Layout ────────────────────────────────────────────────────────────────
void _centerPage(Size viewport, Size pageSize) {
final o = centerOffset(pageSize, viewport, 1.0);
_transform.value = Matrix4.identity()..translateByDouble(o.dx, o.dy, 0, 1);
}
double get _strokeWidth => _tool == CanvasTool.highlighter
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
: (_penConfig?.value.penWidth ?? _penWidthFraction);
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
body: Stack(
children: [
Positioned.fill(child: _buildCanvas()),
SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: _buildToolPalette(cs),
),
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.all(8),
child: RoundIconButton(
icon: Icons.arrow_back,
tooltip: 'Back',
onPressed: () => Navigator.of(context).maybePop(),
),
),
),
SafeArea(
child: Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(8),
child: RoundIconButton(
icon: Icons.picture_as_pdf_outlined,
tooltip: 'Export to PDF',
onPressed: _exportPdf,
),
),
),
),
if (_slideCount > 0)
SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: _buildSlidePill(cs),
),
),
),
],
),
);
}
Widget _buildCanvas() {
final sizes = _slideSizes;
if (sizes == null) {
return const Center(child: CircularProgressIndicator());
}
if (_slideCount == 0) {
return const Center(child: Text('No slides.'));
}
final slide = sizes[_slideIndex] ?? _fallbackSlide;
return LayoutBuilder(
builder: (context, constraints) {
final fitW = constraints.maxWidth / slide.width;
final fitH = constraints.maxHeight / slide.height;
final scale = fitW < fitH ? fitW : fitH;
final pageSize = Size(slide.width * scale, slide.height * scale);
if (_needsCenter) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_centerPage(
Size(constraints.maxWidth, constraints.maxHeight), pageSize);
setState(() => _needsCenter = false);
});
}
return PenCanvas(
key: ValueKey(_slideIndex),
pageSize: pageSize,
strokes: _currentStrokes,
transformationController: _transform,
tool: _tool,
color: _color,
strokeWidth: _strokeWidth,
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,
allowFingerDrawing: _allowFingerDrawing,
onStrokeComplete: _commitStroke,
onEraseStroke: _eraseStroke,
pageWidget: Image.file(
File(widget.slideImagePaths[_slideIndex]),
fit: BoxFit.fill,
gaplessPlayback: true,
),
);
},
);
}
Widget _buildToolPalette(ColorScheme cs) {
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),
),
PaletteDivider(cs: cs),
ToolButton(
icon: Icons.undo,
selected: false,
tooltip: 'Undo',
onPressed:
(_undo[_slideIndex]?.isNotEmpty ?? false) ? _performUndo : null,
),
ToolButton(
icon: Icons.redo,
selected: false,
tooltip: 'Redo',
onPressed:
(_redo[_slideIndex]?.isNotEmpty ?? false) ? _performRedo : null,
),
PaletteDivider(cs: cs),
for (final c in _palette) _colorDot(c, cs),
PaletteDivider(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,
),
],
),
),
);
}
Widget _colorDot(Color c, ColorScheme cs) {
final selected =
_color.toARGB32() == c.toARGB32() && _tool != CanvasTool.eraser;
return GestureDetector(
onTap: () => setState(() {
_color = c;
if (_tool == CanvasTool.eraser) _tool = CanvasTool.pen;
}),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: 24,
height: 24,
decoration: BoxDecoration(
color: c,
shape: BoxShape.circle,
border: Border.all(
color: selected ? cs.onSurface : cs.outlineVariant,
width: selected ? 3 : 1,
),
),
),
);
}
Widget _buildSlidePill(ColorScheme cs) {
final shown = (_scrub ?? (_slideIndex + 1).toDouble()).round();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_showSlider && _slideCount > 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: _slideCount.toDouble(),
value: (_scrub ?? (_slideIndex + 1).toDouble())
.clamp(1, _slideCount.toDouble()),
divisions: _slideCount > 1 ? _slideCount - 1 : null,
onChanged: (v) => setState(() => _scrub = v),
onChangeEnd: (v) {
setState(() => _scrub = null);
_goToSlide(v.round() - 1);
},
),
),
),
),
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 slide',
icon: const Icon(Icons.chevron_left),
onPressed:
_slideIndex > 0 ? () => _goToSlide(_slideIndex - 1) : null,
),
TextButton(
onPressed: _slideCount > 1
? () => setState(() => _showSlider = !_showSlider)
: null,
child: Text(
'$shown / $_slideCount',
style: TextStyle(
color: cs.onSurface,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
tooltip: 'Next slide',
icon: const Icon(Icons.chevron_right),
onPressed: _slideIndex < _slideCount - 1
? () => _goToSlide(_slideIndex + 1)
: null,
),
],
),
),
),
],
);
}
}

View File

@@ -0,0 +1,36 @@
// lib/editor/pdf/slide_export.dart
//
// Pure geometry for exporting pen-first slide annotations to PDF. Because the
// pen canvas captures strokes NORMALIZED to the page rect ([0,1]), the export
// just maps each normalized point into the slide image's draw rectangle on the
// PDF page — no live-widget-size guessing, which is what made the old PPT
// exporter misalign ink (see the removed ppt_annotator_screen comment).
import 'dart:ui' show Offset, Rect, Size;
/// The rectangle a slide [image] occupies when drawn "contain"-fit and centered
/// on a PDF page of size [page]. Mirrors the live canvas's fit-to-view so the
/// exported ink lands exactly where it was drawn.
Rect slideDrawRect(Size page, Size image) {
final iw = image.width <= 0 ? 1.0 : image.width;
final ih = image.height <= 0 ? 1.0 : image.height;
final scale = (page.width / iw) < (page.height / ih)
? page.width / iw
: page.height / ih;
final drawW = iw * scale;
final drawH = ih * scale;
final offX = (page.width - drawW) / 2;
final offY = (page.height - drawH) / 2;
return Rect.fromLTWH(offX, offY, drawW, drawH);
}
/// Map a normalized stroke point ([0,1] of the page rect) to an absolute point
/// inside the slide's [drawRect] on the PDF page.
Offset normToSlide(double nx, double ny, Rect drawRect) =>
Offset(drawRect.left + nx * drawRect.width,
drawRect.top + ny * drawRect.height);
/// Absolute pen width (PDF units) for a stroke whose width is a fraction of the
/// page width, scaled into [drawRect].
double slideStrokeWidth(double normalizedWidth, Rect drawRect) =>
normalizedWidth * drawRect.width;

View File

@@ -10,7 +10,7 @@ import '../editor/canvas/pen_editor_screen.dart';
import '../services/pdf_service.dart';
import '../services/pptx_service.dart';
import '../editor/canvas/pen_note_screen.dart';
import 'ppt_annotator_screen.dart';
import '../editor/canvas/pen_slide_screen.dart';
import 'search_screen.dart';
import 'settings_screen.dart';
import 'split_view_screen.dart';
@@ -222,7 +222,7 @@ class HomeScreen extends ConsumerWidget {
if (context.mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PptAnnotatorScreen(
builder: (_) => PenSlideScreen(
filePath: filePath,
slideImagePaths: slideImages,
extractedText: extractedText.isEmpty ? null : extractedText,
@@ -527,7 +527,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
);
}
// [L2] Route by docType: pdf → PenEditorScreen (pen-first), ppt/pptx → PptAnnotatorScreen
// [L2] Route by docType: pdf → PenEditorScreen (pen-first), ppt/pptx → PenSlideScreen
Future<void> _openDocument(BuildContext context) async {
final document = widget.document;
final isPdf = document.docType == 'pdf';
@@ -539,7 +539,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
),
);
} else {
// PPT/PPTX: convert to images then push PptAnnotatorScreen
// PPT/PPTX: convert to images then push PenSlideScreen
if (mounted) {
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Processing presentation...')),
@@ -557,7 +557,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
}
Navigator.of(this.context).push(
MaterialPageRoute(
builder: (_) => PptAnnotatorScreen(
builder: (_) => PenSlideScreen(
filePath: document.filePath,
slideImagePaths: slideImages,
extractedText: extractedText.isEmpty ? null : extractedText,

View File

@@ -1,528 +0,0 @@
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import 'package:syncfusion_flutter_pdf/pdf.dart';
import '../models/ink_stroke.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../services/undo_manager.dart';
import '../utils/stroke_stabilizer.dart';
import '../widgets/annotation_toolbar.dart';
import '../widgets/ink_canvas.dart';
/// Per-slide annotation state. The [UndoManager] is the single source of
/// truth for a slide's strokes; [strokes] reflects its current contents so
/// the live canvas and the PDF export always render what was actually drawn.
class _SlideAnnotations {
final UndoManager undoManager = UndoManager();
List<InkStroke> get strokes => undoManager.currentStrokes;
}
/// Screen that displays PPTX slides with an ink annotation overlay.
///
/// Each slide is shown as an image in a [PageView]. A transparent [InkCanvas]
/// sits on top of each slide so the user can annotate freely. Annotations are
/// stored per-slide and can be exported as a PDF.
class PptAnnotatorScreen extends StatefulWidget {
final String filePath;
final List<String> slideImagePaths;
final String? extractedText;
const PptAnnotatorScreen({
super.key,
required this.filePath,
required this.slideImagePaths,
this.extractedText,
});
@override
State<PptAnnotatorScreen> createState() => _PptAnnotatorScreenState();
}
class _PptAnnotatorScreenState extends State<PptAnnotatorScreen> {
late final PageController _pageController;
late final Map<int, _SlideAnnotations> _annotations;
int _currentPage = 0;
bool _isDrawing = false;
bool _showTextPanel = false;
// Set to true once the unsaved-annotations warning SnackBar has been shown.
bool _hasShownUnsavedWarning = false;
// Toolbar state
PenTool _currentTool = PenTool.pen;
Color _currentColor = Colors.black;
double _currentStrokeWidth = 2.0;
bool _filled = false;
PressureCurveType _pressureCurveType = PressureCurveType.linear;
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
// Derived
late final String _fileName;
late final int _slideCount;
late final String _extractedText;
PressureCurve get _pressureCurve {
switch (_pressureCurveType) {
case PressureCurveType.linear:
return PressureCurve.linear;
case PressureCurveType.soft:
return PressureCurve.soft;
case PressureCurveType.hard:
return PressureCurve.hard;
case PressureCurveType.custom:
return const PressureCurve(type: PressureCurveType.custom);
}
}
UndoManager get _currentUndoManager =>
_annotations.putIfAbsent(_currentPage, _SlideAnnotations.new).undoManager;
@override
void initState() {
super.initState();
_fileName = p.basename(widget.filePath);
_slideCount = widget.slideImagePaths.length;
_extractedText = widget.extractedText ?? '';
_pageController = PageController();
_annotations = {};
for (var i = 0; i < _slideCount; i++) {
_annotations[i] = _SlideAnnotations();
}
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
// -- Drawing callbacks --
void _onStrokeComplete(InkStroke stroke) {
setState(() {
_currentUndoManager.addStroke(stroke);
});
// Warn once per session that PPT annotations are not auto-saved.
if (!_hasShownUnsavedWarning) {
_hasShownUnsavedWarning = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"PPT ink isn't saved automatically — use Export to PDF to keep your annotations.",
),
duration: Duration(seconds: 5),
),
);
});
}
}
void _onErase(String strokeId, List<InkStroke> replacements) {
setState(() {
final original = _currentUndoManager.currentStrokes
.where((s) => s.id == strokeId)
.firstOrNull;
if (original != null) {
_currentUndoManager.removeStroke(original, replacements: replacements);
}
});
}
// -- Export --
Future<void> _exportPdf() async {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Exporting PDF...')));
try {
final bytes = await _buildPdfBytes();
if (!mounted) return;
final dir = await _getExportDir();
final baseName = p.basenameWithoutExtension(_fileName);
final outPath = p.join(dir.path, '${baseName}_annotated.pdf');
await File(outPath).writeAsBytes(bytes);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('PDF saved: $outPath')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Export failed: $e')));
}
}
Future<Directory> _getExportDir() async {
try {
final home = Platform.environment['HOME'];
if (home != null) {
final dir = Directory(p.join(home, 'Documents', 'BadNote'));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
} catch (_) {}
return Directory.current;
}
Future<Uint8List> _buildPdfBytes() async {
final doc = PdfDocument();
doc.pageSettings.margins.all = 0;
for (var i = 0; i < _slideCount; i++) {
final page = doc.pages.add();
final pageSize = page.getClientSize();
// Draw slide image
final imgPath = widget.slideImagePaths[i];
try {
final imgBytes = await File(imgPath).readAsBytes();
final bitmap = PdfBitmap(imgBytes);
final imgW = bitmap.width.toDouble();
final imgH = bitmap.height.toDouble();
final scale = min(pageSize.width / imgW, pageSize.height / imgH);
final drawW = imgW * scale;
final drawH = imgH * scale;
final offX = (pageSize.width - drawW) / 2;
final offY = (pageSize.height - drawH) / 2;
final imgRect = Rect.fromLTWH(offX, offY, drawW, drawH);
page.graphics.drawImage(bitmap, imgRect);
// Draw ink strokes
final annots = _annotations[i];
if (annots != null && annots.strokes.isNotEmpty) {
// KNOWN LIMITATION: strokes are captured in the live viewer's
// full-fill pixel space (the InkCanvas is Positioned.fill over the
// whole slide area, while the slide image is BoxFit.contain inside
// it). The scale below is derived from the PDF page layout, not the
// live widget size, so exported ink can be misaligned/scaled wrong.
// A correct fix normalizes strokes to [0,1] of the *rendered image
// rect* at capture time (mirroring PdfAnnotationLayer) and maps that
// to the PDF draw rect here. Requires on-device visual verification.
final imgAspect = imgW / imgH;
final pageAspect = pageSize.width / pageSize.height;
double widgetW, widgetH;
if (imgAspect > pageAspect) {
widgetW = pageSize.width;
widgetH = pageSize.width / imgAspect;
} else {
widgetH = pageSize.height;
widgetW = pageSize.height * imgAspect;
}
final scaleX = drawW / widgetW;
final scaleY = drawH / widgetH;
for (final stroke in annots.strokes) {
if (stroke.tool == PenTool.eraser) continue;
if (stroke.points.length < 2) continue;
final r = (stroke.color >> 16) & 0xFF;
final g = (stroke.color >> 8) & 0xFF;
final b = stroke.color & 0xFF;
final pdfColor = PdfColor(r, g, b);
final path = PdfPath();
path.startFigure();
for (var j = 0; j < stroke.points.length - 1; j++) {
final pt1 = stroke.points[j];
final pt2 = stroke.points[j + 1];
path.addLine(
Offset(offX + pt1.x * scaleX, offY + pt1.y * scaleY),
Offset(offX + pt2.x * scaleX, offY + pt2.y * scaleY),
);
}
page.graphics.drawPath(
path,
pen: PdfPen(pdfColor, width: stroke.strokeWidth),
);
}
}
} catch (_) {
page.graphics.drawRectangle(
brush: PdfSolidBrush(PdfColor(230, 230, 230)),
bounds: Rect.fromLTWH(0, 0, pageSize.width, pageSize.height),
);
}
}
final bytes = await doc.save();
doc.dispose();
return Uint8List.fromList(bytes);
}
// -- UI --
@override
Widget build(BuildContext context) {
// No slides to annotate: show an empty state and skip the toolbar, which
// would otherwise dereference a non-existent slide's annotation state.
if (_slideCount == 0) {
return Scaffold(
appBar: AppBar(
title: Text(_fileName, style: const TextStyle(fontSize: 16)),
),
body: const Center(child: Text('No slides to display')),
);
}
return Scaffold(
appBar: AppBar(
title: Text(_fileName, style: const TextStyle(fontSize: 16)),
actions: [
if (_extractedText.isNotEmpty)
IconButton(
icon: Icon(
_showTextPanel
? Icons.text_snippet
: Icons.text_snippet_outlined,
),
tooltip: 'Toggle extracted text',
onPressed: () => setState(() => _showTextPanel = !_showTextPanel),
),
IconButton(
icon: const Icon(Icons.picture_as_pdf),
tooltip: 'Export as PDF',
onPressed: _exportPdf,
),
],
),
body: Column(
children: [
AnnotationToolbar(
currentTool: _currentTool,
currentColor: _currentColor,
currentStrokeWidth: _currentStrokeWidth,
filled: _filled,
pressureCurveType: _pressureCurveType,
stabilizationLevel: _stabilizationLevel,
canUndo: _currentUndoManager.canUndo,
canRedo: _currentUndoManager.canRedo,
onToolChanged: (tool) => setState(() => _currentTool = tool),
onColorChanged: (color) => setState(() => _currentColor = color),
onStrokeWidthChanged: (w) =>
setState(() => _currentStrokeWidth = w),
onFilledChanged: (f) => setState(() => _filled = f),
onPressureCurveChanged: (v) =>
setState(() => _pressureCurveType = v),
onStabilizationChanged: (v) =>
setState(() => _stabilizationLevel = v),
onUndo: _undo,
onRedo: _redo,
),
Expanded(
child: Row(
children: [
Expanded(child: _buildSlideViewer()),
if (_showTextPanel) _buildTextPanel(),
],
),
),
_buildPageIndicator(),
],
),
);
}
Widget _buildSlideViewer() {
if (_slideCount == 0) {
return const Center(child: Text('No slides to display'));
}
return Listener(
onPointerDown: (_) => setState(() => _isDrawing = true),
onPointerUp: (_) => setState(() => _isDrawing = false),
child: PageView.builder(
controller: _pageController,
physics: _isDrawing ? const NeverScrollableScrollPhysics() : null,
itemCount: _slideCount,
onPageChanged: (page) => setState(() => _currentPage = page),
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.all(8),
child: Stack(
children: [
// Slide image (background)
Positioned.fill(
child: Image.file(
File(widget.slideImagePaths[index]),
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) => Container(
color: Colors.grey.shade200,
child: Center(
child: Text(
'Slide ${index + 1}',
style: TextStyle(
fontSize: 24,
color: Colors.grey.shade500,
),
),
),
),
),
),
// Ink annotation overlay (foreground)
Positioned.fill(
child: InkCanvas(
strokes: _annotations[index]?.strokes ?? [],
onStrokeComplete: _onStrokeComplete,
onErase: _onErase,
tool: _currentTool,
color: _currentColor,
strokeWidth: _currentStrokeWidth,
pressureCurve: _pressureCurve,
stabilizationLevel: _stabilizationLevel,
filled: _filled,
),
),
],
),
);
},
),
);
}
Widget _buildPageIndicator() {
if (_slideCount == 0) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: Theme.of(context).colorScheme.surface,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Previous button — always present for both modes.
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: _currentPage > 0
? () => _pageController.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
)
: null,
),
// Dot row for small decks; compact text counter for large decks.
if (_slideCount <= 12)
...List.generate(_slideCount, (i) {
final isActive = i == _currentPage;
return GestureDetector(
onTap: () => _pageController.animateToPage(
i,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
),
child: Container(
width: isActive ? 12 : 8,
height: isActive ? 12 : 8,
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isActive
? Theme.of(context).colorScheme.primary
: Colors.grey.shade400,
),
),
);
})
else
Text(
'${_currentPage + 1} / $_slideCount',
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
// Next button — always present for both modes.
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: _currentPage < _slideCount - 1
? () => _pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
)
: null,
),
const Spacer(),
// Slide counter is always shown at the trailing end for dot mode;
// the compact text above already serves this role for large decks.
if (_slideCount <= 12)
Text(
'${_currentPage + 1} / $_slideCount',
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
],
),
);
}
Widget _buildTextPanel() {
return SizedBox(
width: 280,
child: Card(
margin: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
),
child: Row(
children: [
const Icon(Icons.text_fields, size: 18),
const SizedBox(width: 8),
Text(
'Extracted Text',
style: Theme.of(context).textTheme.titleSmall,
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: () => setState(() => _showTextPanel = false),
),
],
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: SelectableText(
_extractedText,
style: Theme.of(context).textTheme.bodySmall,
),
),
),
],
),
),
);
}
// -- Dialogs --
void _undo() {
setState(() => _currentUndoManager.undo());
}
void _redo() {
setState(() => _currentUndoManager.redo());
}
}