import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; import 'package:uuid/uuid.dart'; import '../editor/canvas/pen_canvas.dart'; import '../editor/canvas/pen_stroke.dart'; import '../editor/notebook/ink_stroke_adapter.dart'; import '../models/ink_stroke.dart'; import '../models/pen_tool.dart'; import '../models/pressure_curve.dart'; import '../services/database_service.dart'; import '../services/undo_manager.dart'; import '../utils/stroke_stabilizer.dart'; import '../widgets/annotation_toolbar.dart'; /// Split-view derivation mode: left pane = reference PDF, right pane = infinite /// scratchpad for formula derivation. Scratchpad strokes are persisted per /// document via [DatabaseService.saveScratchpad] / [DatabaseService.loadScratchpad]. class SplitViewScreen extends StatefulWidget { final String filePath; final String documentId; const SplitViewScreen({ super.key, required this.filePath, required this.documentId, }); @override State createState() => _SplitViewState(); } class _SplitViewState extends State { // -- PDF (left pane) -- final PdfViewerController _pdfController = PdfViewerController(); int _currentPage = 0; int _pageCount = 0; String _fileName = ''; // -- Split divider -- double _leftPaneFraction = 0.5; bool _isDraggingDivider = false; // -- Scratchpad (right pane) -- // The scratchpad is an infinite WORLD: strokes are stored in absolute world // pixels ([InkStroke], unchanged persistence format), and rendered through the // performant PenCanvas by normalizing against the CURRENT world size. When the // world auto-expands, the stored world coords don't move — only the // normalization divisor grows — so ink stays put with zero drift. final UndoManager _undoManager = UndoManager(); List _strokes = []; double _canvasWidth = 4000; double _canvasHeight = 4000; static const _uuid = Uuid(); /// Pan/zoom transform for the scratchpad world (PenCanvas drives this). final TransformationController _scratchTransform = TransformationController(); /// Set once the initial view has been framed onto existing ink. bool _scratchCentered = false; Size get _worldSize => Size(_canvasWidth, _canvasHeight); /// Maps the scratchpad toolbar's [PenTool] to the pen-canvas tool. Shapes and /// text fall back to pen (the pen-first scratchpad is freehand). CanvasTool get _canvasTool => switch (_currentTool) { PenTool.eraser => CanvasTool.eraser, PenTool.highlighter => CanvasTool.highlighter, _ => CanvasTool.pen, }; // -- Tool state -- PenTool _currentTool = PenTool.pen; Color _currentColor = Colors.black; double _currentStrokeWidth = 2.0; bool _filled = false; PressureCurveType _pressureCurveType = PressureCurveType.linear; StabilizationLevel _stabilizationLevel = StabilizationLevel.none; // -- Auto-save debounce -- Timer? _saveTimer; bool _dirty = false; // -- Page link markers (optional feature) -- final List<_PageLink> _pageLinks = []; static const double _edgeThreshold = 200.0; static const double _expandAmount = 1000.0; @override void initState() { super.initState(); _loadScratchpad(); } @override void dispose() { _saveTimer?.cancel(); _saveImmediate(); _pdfController.dispose(); _scratchTransform.dispose(); super.dispose(); } // -- Persistence -- Future _loadScratchpad() async { final db = await DatabaseService.getInstance(); final strokes = await db.loadScratchpad(widget.documentId); if (mounted) { setState(() { // Keep only freehand strokes so the canvas list stays 1:1 with the // undo manager (shapes/text have no pen-canvas representation). final freehand = strokes.where((s) => isFreehandTool(s.tool)).toList(); _strokes = freehand; for (final s in freehand) { _undoManager.addStroke(s); } }); } } void _scheduleSave() { _dirty = true; _saveTimer?.cancel(); _saveTimer = Timer(const Duration(seconds: 3), _saveImmediate); } Future _saveImmediate() async { if (!_dirty) return; _dirty = false; final db = await DatabaseService.getInstance(); final json = jsonEncode(_strokes.map((s) => s.toJson()).toList()); await db.saveScratchpad(widget.documentId, json); } // -- Scratchpad stroke callbacks -- /// PenCanvas committed a stroke (normalized to the current world). Convert it /// to absolute world coords for storage. void _onStrokeComplete(PenStroke pen) { final stroke = inkStrokeFromPen(pen, _worldSize, id: _uuid.v4(), createdAt: DateTime.now()); setState(() { _strokes.add(stroke); _undoManager.addStroke(stroke); _checkCanvasExpansion(stroke); }); _scheduleSave(); } /// PenCanvas erased through stroke [index] (into [_strokes]); [replacements] /// are the surviving sub-strokes (normalized) — convert back to world coords. void _onErase(int index, List replacements) { if (index < 0 || index >= _strokes.length) return; setState(() { final original = _strokes[index]; final inkReplacements = [ for (final r in replacements) inkStrokeFromPen(r, _worldSize, id: _uuid.v4(), createdAt: DateTime.now()), ]; _undoManager.removeStroke(original, replacements: inkReplacements); _strokes = List.from(_undoManager.currentStrokes); }); _scheduleSave(); } void _undo() { setState(() { _undoManager.undo(); _strokes = List.from(_undoManager.currentStrokes); }); _scheduleSave(); } void _redo() { setState(() { _undoManager.redo(); _strokes = List.from(_undoManager.currentStrokes); }); _scheduleSave(); } // -- Auto-expand canvas -- void _checkCanvasExpansion(InkStroke stroke) { double maxRight = 0; double maxBottom = 0; for (final p in stroke.points) { if (p.x > maxRight) maxRight = p.x; if (p.y > maxBottom) maxBottom = p.y; } bool expanded = false; if (maxRight > _canvasWidth - _edgeThreshold) { _canvasWidth += _expandAmount; expanded = true; } if (maxBottom > _canvasHeight - _edgeThreshold) { _canvasHeight += _expandAmount; expanded = true; } if (expanded) setState(() {}); } // -- Divider drag -- void _onDividerDragStart(DragStartDetails details) { setState(() => _isDraggingDivider = true); } void _onDividerDragUpdate( DragUpdateDetails details, BoxConstraints constraints, ) { final renderWidth = constraints.maxWidth; if (renderWidth <= 0) return; final delta = details.delta.dx / renderWidth; setState(() { _leftPaneFraction = (_leftPaneFraction + delta).clamp(0.2, 0.8); }); } void _onDividerDragEnd(DragEndDetails details) { setState(() => _isDraggingDivider = false); } // -- PDF page navigation -- void _prevPage() { if (_currentPage > 0) { _pdfController.previousPage(); } } void _nextPage() { if (_currentPage < _pageCount - 1) { _pdfController.nextPage(); } } // -- Page link creation (long-press on left pane) -- void _onPdfLongPress(int pageNumber) { // Place a page link marker at the current scratchpad viewport center. // We approximate the viewport center as (0, 0) since InteractiveViewer // manages its own transform — the user can reposition by panning. setState(() { _pageLinks.add( _PageLink( pageNumber: pageNumber, position: const Offset(100, 100), // default top-left area ), ); }); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Page link marker added for page $pageNumber')), ); } void _onPageLinkTap(_PageLink link) { _pdfController.jumpToPage(link.pageNumber); setState(() { _currentPage = link.pageNumber - 1; }); } void _deletePageLink(_PageLink link) { setState(() { _pageLinks.remove(link); }); } // -- Build -- @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text( _fileName.isEmpty ? 'Split View' : _fileName, style: const TextStyle(fontSize: 16), ), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () { _saveImmediate(); Navigator.of(context).pop(); }, ), actions: [ // Left pane page navigation IconButton( icon: const Icon(Icons.navigate_before), tooltip: 'Previous page (PDF)', onPressed: _currentPage > 0 ? _prevPage : null, ), Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: Center( child: Text( '${_currentPage + 1} / $_pageCount', style: const TextStyle(fontSize: 13), ), ), ), IconButton( icon: const Icon(Icons.navigate_next), tooltip: 'Next page (PDF)', onPressed: _currentPage < _pageCount - 1 ? _nextPage : null, ), const SizedBox(width: 8), // Canvas info Tooltip( message: 'Scratchpad size: ${_canvasWidth.round()} x ${_canvasHeight.round()}', child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Center( child: Text( '${_canvasWidth.round()}x${_canvasHeight.round()}', style: const TextStyle(fontSize: 11, color: Colors.grey), ), ), ), ), ], ), body: Column( children: [ // Label clarifying that the toolbar controls the scratchpad pane. Padding( padding: const EdgeInsets.only(left: 12, top: 4), child: Align( alignment: Alignment.centerLeft, child: Text( 'Scratchpad tools', style: Theme.of(context).textTheme.labelSmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), ), // Toolbar (applies to scratchpad only) AnnotationToolbar( currentTool: _currentTool, currentColor: _currentColor, currentStrokeWidth: _currentStrokeWidth, filled: _filled, pressureCurveType: _pressureCurveType, stabilizationLevel: _stabilizationLevel, canUndo: _undoManager.canUndo, canRedo: _undoManager.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, ), // Split view body Expanded( child: LayoutBuilder( builder: (context, constraints) { final totalWidth = constraints.maxWidth; final leftWidth = totalWidth * _leftPaneFraction; final rightWidth = totalWidth - leftWidth - 12; // 12px divider hit area return Row( children: [ // Left pane: PDF reference (read-only) SizedBox(width: leftWidth, child: _buildPdfPane()), // Draggable divider: 12px hit area, 4px visual strip. GestureDetector( onHorizontalDragStart: _onDividerDragStart, onHorizontalDragUpdate: (d) => _onDividerDragUpdate(d, constraints), onHorizontalDragEnd: _onDividerDragEnd, child: MouseRegion( cursor: SystemMouseCursors.resizeColumn, child: SizedBox( width: 12, child: Center( child: Container( width: 4, color: _isDraggingDivider ? Theme.of(context).colorScheme.primary : Theme.of(context).dividerColor, ), ), ), ), ), // Right pane: Infinite scratchpad SizedBox(width: rightWidth, child: _buildScratchpadPane()), ], ); }, ), ), ], ), ); } Widget _buildPdfPane() { return Stack( children: [ GestureDetector( onLongPress: () { // Long-press on PDF to create page link marker _onPdfLongPress(_currentPage + 1); }, child: SfPdfViewer.file( File(widget.filePath), controller: _pdfController, canShowScrollHead: true, canShowScrollStatus: true, onPageChanged: (PdfPageChangedDetails details) { setState(() { _currentPage = details.newPageNumber - 1; }); }, onDocumentLoaded: (PdfDocumentLoadedDetails details) { setState(() { _pageCount = details.document.pages.count; _fileName = widget.filePath.split(Platform.pathSeparator).last; }); }, ), ), // Page link markers overlay (on PDF pane, showing linked pages) if (_pageLinks.isNotEmpty) Positioned(bottom: 8, left: 8, child: _buildPageLinkChips()), ], ); } Widget _buildPageLinkChips() { return Wrap( spacing: 4, runSpacing: 4, children: _pageLinks.map((link) { return GestureDetector( onTap: () => _onPageLinkTap(link), onLongPress: () => _deletePageLink(link), child: Chip( avatar: const Icon(Icons.link, size: 14, color: Colors.white), label: Text( 'p${link.pageNumber}', style: const TextStyle(fontSize: 11, color: Colors.white), ), backgroundColor: Colors.blue.shade600, padding: EdgeInsets.zero, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: VisualDensity.compact, ), ); }).toList(), ); } Widget _buildScratchpadPane() { // Render the world through the performant PenCanvas: strokes normalized // against the current world size; toolbar width is in world pixels, so the // pen-canvas fraction is width / worldWidth. return LayoutBuilder( builder: (context, constraints) { // On first layout, frame the view so existing ink is actually visible // (otherwise identity shows only the empty top-left corner of the huge // world). Empty scratchpad falls back to a comfortable 1:1 near origin. if (!_scratchCentered) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _frameScratchpad( Size(constraints.maxWidth, constraints.maxHeight)); setState(() => _scratchCentered = true); }); } return Container( color: Theme.of(context).scaffoldBackgroundColor, child: PenCanvas( pageSize: _worldSize, strokes: penStrokesFromInk(_strokes, _worldSize), transformationController: _scratchTransform, tool: _canvasTool, color: _currentColor, strokeWidth: _currentStrokeWidth / _canvasWidth, // The world is huge, so allow zooming further out to survey it. minScale: 0.1, maxScale: 8.0, onStrokeComplete: _onStrokeComplete, onEraseStroke: _onErase, pageWidget: const ColoredBox(color: Colors.white), ), ); }, ); } /// Position the scratchpad so existing ink is on-screen. Fits the strokes' /// world bounding box into [pane] (with padding, scale clamped); for an empty /// scratchpad, shows the top-left working area at 1:1. void _frameScratchpad(Size pane) { if (pane.isEmpty) return; if (_strokes.isEmpty) { _scratchTransform.value = Matrix4.identity(); return; } double minX = double.infinity, minY = double.infinity; double maxX = -double.infinity, maxY = -double.infinity; for (final s in _strokes) { for (final p in s.points) { if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; } } if (minX > maxX) { _scratchTransform.value = Matrix4.identity(); return; } const pad = 80.0; final boxW = (maxX - minX) + pad * 2; final boxH = (maxY - minY) + pad * 2; final scale = (pane.width / boxW < pane.height / boxH ? pane.width / boxW : pane.height / boxH) .clamp(0.15, 1.5); final cx = (minX + maxX) / 2; final cy = (minY + maxY) / 2; final tx = pane.width / 2 - scale * cx; final ty = pane.height / 2 - scale * cy; _scratchTransform.value = Matrix4.identity() ..setEntry(0, 0, scale) ..setEntry(1, 1, scale) ..setEntry(2, 2, scale) ..setTranslationRaw(tx, ty, 0); } } /// A marker linking a scratchpad position to a specific PDF page. class _PageLink { final int pageNumber; final Offset position; const _PageLink({required this.pageNumber, required this.position}); }