// 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 '../engine/brush.dart'; import '../engine/shape_geometry.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 '../ui/page_nav_shortcuts.dart'; import '../ui/pen_settings_page.dart'; import 'editor_tool.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 slideImagePaths; final String? extractedText; @override State createState() => _PenSlideScreenState(); } class _PenSlideScreenState extends State { int _slideIndex = 0; final Map> _strokesBySlide = {}; final Map>> _undo = {}; final Map>> _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? _slideSizes; /// The single active-tool state (shared model across the 3 editors). EditorToolKind _tool = EditorToolKind.brush; /// Selected brush for the BRUSH tool. Highlighter tool uses the highlighter /// brush; local state only (not persisted — TODO(brush-persist-selection)). BrushKind _penBrush = BrushKind.fountainPen; /// Selected shape for the SHAPE tool. ShapeKind _shapeKind = ShapeKind.line; /// Index of the currently selected committed stroke (SELECT tool), or null. int? _selectedStroke; /// rnote-style per-brush color memory (see PenNoteScreen). In-memory only. final Map _brushColors = { BrushKind.fountainPen: Colors.black, BrushKind.ballpoint: Colors.blue, BrushKind.pencil: Colors.green, BrushKind.highlighter: Colors.orange, }; BrushKind get _activeColorBrush => _tool == EditorToolKind.highlighter ? BrushKind.highlighter : _penBrush; Color get _color => _brushColors[_activeColorBrush] ?? 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 _palette = kInkPalette; int get _slideCount => widget.slideImagePaths.length; List get _currentStrokes => _strokesBySlide[_slideIndex] ?? const []; @override void initState() { super.initState(); PenInputService.instance.start(); _loadSlideSizes(); _initPenConfig(); } Future _loadSlideSizes() async { final sizes = {}; 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 _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.from(_currentStrokes)); _redo[_slideIndex]?.clear(); } void _commitStroke(PenStroke stroke) { setState(() { _pushUndo(); _strokesBySlide[_slideIndex] = [..._currentStrokes, stroke]; }); } void _eraseStroke(int index, List 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.from(_currentStrokes)); _strokesBySlide[_slideIndex] = stack.removeLast(); }); } void _performRedo() { final stack = _redo[_slideIndex]; if (stack == null || stack.isEmpty) return; setState(() { (_undo[_slideIndex] ??= []).add(List.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; _selectedStroke = null; // selection is per-slide }); } // ── Export ────────────────────────────────────────────────────────────────── Future _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 _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 _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 []) { 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 == EditorToolKind.highlighter ? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction) : (_penConfig?.value.penWidth ?? _penWidthFraction); // ── SELECT tool: select / move / delete (per-slide, reuses the undo stacks) ── void _selectStroke(int? index) { setState(() => _selectedStroke = index); } void _moveStroke(int index, double dx, double dy, bool isDragStart) { final strokes = _currentStrokes; if (index < 0 || index >= strokes.length) return; setState(() { if (isDragStart) _pushUndo(); final next = List.from(strokes); next[index] = translateStroke(next[index], dx, dy); _strokesBySlide[_slideIndex] = next; }); } void _deleteSelected() { final idx = _selectedStroke; final strokes = _currentStrokes; if (idx == null || idx < 0 || idx >= strokes.length) return; setState(() { _pushUndo(); _strokesBySlide[_slideIndex] = [ ...strokes.sublist(0, idx), ...strokes.sublist(idx + 1), ]; _selectedStroke = null; }); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return pageNavShortcuts( onPrevious: _slideIndex > 0 ? () => _goToSlide(_slideIndex - 1) : null, onNext: _slideIndex < _slideCount - 1 ? () => _goToSlide(_slideIndex + 1) : null, onFirst: _slideCount > 0 ? () => _goToSlide(0) : null, onLast: _slideCount > 0 ? () => _goToSlide(_slideCount - 1) : null, child: 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: editorToolToCanvas(_tool), brush: _penBrush, shapeKind: _shapeKind, color: _color, strokeWidth: _strokeWidth, selectedStrokeIndex: _selectedStroke, onSelectStroke: _selectStroke, onMoveStroke: _moveStroke, 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: [ BrushPickerButton( selected: _penBrush, active: _tool == EditorToolKind.brush, tooltip: 'Brush', labelFor: brushLabelEn, colorFor: (b) => _brushColors[b] ?? Colors.black, onSelected: (b) => setState(() { _penBrush = b; _tool = EditorToolKind.brush; }), ), ToolButton( icon: Icons.brush_outlined, selected: _tool == EditorToolKind.highlighter, tooltip: 'Highlighter', onPressed: () => setState(() => _tool = EditorToolKind.highlighter), ), ToolButton( icon: Icons.cleaning_services_outlined, selected: _tool == EditorToolKind.eraser, tooltip: 'Eraser', onPressed: () => setState(() => _tool = EditorToolKind.eraser), ), ToolButton( icon: Icons.ads_click, selected: _tool == EditorToolKind.select, tooltip: 'Select', onPressed: () => setState(() => _tool = EditorToolKind.select), ), ShapePickerButton( selected: _shapeKind, active: _tool == EditorToolKind.shape, tooltip: 'Shape', labelFor: shapeLabelEn, onActivate: () => setState(() => _tool = EditorToolKind.shape), onSelected: (s) => setState(() { _shapeKind = s; _tool = EditorToolKind.shape; }), ), if (_tool == EditorToolKind.select && _selectedStroke != null) ToolButton( icon: Icons.delete_outline, selected: false, tooltip: 'Delete selection', onPressed: _deleteSelected, ), 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, ), ToolButton( icon: Icons.settings_outlined, selected: false, tooltip: 'Pen settings (width, pressure, eraser…)', onPressed: _penConfig != null ? () => showPenSettingsSheet(context, _penConfig!) : null, ), ], ), ), ); } Widget _colorDot(Color c, ColorScheme cs) { final selected = _color.toARGB32() == c.toARGB32() && _tool != EditorToolKind.eraser && _tool != EditorToolKind.select; return GestureDetector( onTap: () => setState(() { if (_tool == EditorToolKind.eraser || _tool == EditorToolKind.select) { _tool = EditorToolKind.brush; } _brushColors[_activeColorBrush] = c; }), 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, ), ], ), ), ), ], ); } }