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 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 slideImagePaths; final String? extractedText; const PptAnnotatorScreen({ super.key, required this.filePath, required this.slideImagePaths, this.extractedText, }); @override State createState() => _PptAnnotatorScreenState(); } class _PptAnnotatorScreenState extends State { late final PageController _pageController; late final Map _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 replacements) { setState(() { final original = _currentUndoManager.currentStrokes .where((s) => s.id == strokeId) .firstOrNull; if (original != null) { _currentUndoManager.removeStroke(original, replacements: replacements); } }); } // -- Export -- Future _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 _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 _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()); } }