import 'package:flutter/material.dart'; import 'package:flutter/services.dart' hide UndoManager; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/ink_stroke.dart'; import '../models/note.dart'; import '../models/pen_tool.dart'; import '../models/pressure_curve.dart'; import '../providers/note_provider.dart'; import '../providers/ocr_provider.dart'; import '../services/undo_manager.dart'; import '../utils/stroke_stabilizer.dart'; import '../widgets/annotation_toolbar.dart'; import '../widgets/ink_canvas.dart'; class NoteEditorScreen extends ConsumerStatefulWidget { final Note? note; const NoteEditorScreen({super.key, this.note}); @override ConsumerState createState() => _NoteEditorScreenState(); } class _NoteEditorScreenState extends ConsumerState { final UndoManager _undoManager = UndoManager(); PenTool _currentTool = PenTool.pen; Color _currentColor = Colors.black; double _currentStrokeWidth = 2.0; bool _filled = false; String _title = 'Untitled'; final TextEditingController _titleController = TextEditingController(); PressureCurveType _pressureCurveType = PressureCurveType.linear; StabilizationLevel _stabilizationLevel = StabilizationLevel.none; final TransformationController _zoomController = TransformationController(); double _zoomLevel = 1.0; bool _isDirty = false; Note? get _existingNote => widget.note; 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); } } @override void initState() { super.initState(); if (_existingNote != null) { _title = _existingNote!.title; for (final stroke in _existingNote!.strokes) { _undoManager.addStroke(stroke); } } _titleController.text = _title; } @override void dispose() { _titleController.dispose(); _zoomController.dispose(); super.dispose(); } void _onStrokeComplete(InkStroke stroke) { setState(() { _undoManager.addStroke(stroke); _isDirty = true; }); } void _onErase(String strokeId, List replacements) { setState(() { final original = _undoManager.currentStrokes .where((s) => s.id == strokeId) .firstOrNull; if (original != null) { _undoManager.removeStroke(original, replacements: replacements); _isDirty = true; } }); } void _undo() { setState(() { _undoManager.undo(); _isDirty = true; }); } void _redo() { setState(() { _undoManager.redo(); _isDirty = true; }); } Future _save() async { final notifier = ref.read(noteListProvider.notifier); final now = DateTime.now(); Note savedNote; if (_existingNote != null) { final updated = _existingNote!.copyWith( title: _title, strokes: _undoManager.currentStrokes.toList(), updatedAt: now, ); await notifier.updateNote(updated); savedNote = updated; } else { final note = await notifier.createNote(title: _title); final updated = note.copyWith( strokes: _undoManager.currentStrokes.toList(), ); await notifier.updateNote(updated); savedNote = updated; } if (!mounted) return; setState(() { _isDirty = false; }); _runLocalOcr(savedNote); } /// Run local OCR and index results for search. void _runLocalOcr(Note note) { final noteId = note.id; ref.read(ocrStatusProvider.notifier).state = { ...ref.read(ocrStatusProvider), noteId: OcrStatus.processing, }; ref .read(ocrServiceProvider) .processNote(note) .then((_) { if (!mounted) return; ref.read(ocrStatusProvider.notifier).state = { ...ref.read(ocrStatusProvider), noteId: OcrStatus.done, }; }) .catchError((_) { if (!mounted) return; ref.read(ocrStatusProvider.notifier).state = { ...ref.read(ocrStatusProvider), noteId: OcrStatus.failed, }; }); } void _zoomIn() { final newLevel = (_zoomLevel + 0.25).clamp(0.5, 5.0); _applyZoom(newLevel); } void _zoomOut() { final newLevel = (_zoomLevel - 0.25).clamp(0.5, 5.0); _applyZoom(newLevel); } void _zoomReset() { _applyZoom(1.0); } void _applyZoom(double level) { setState(() => _zoomLevel = level); _zoomController.value = Matrix4.diagonal3Values(level, level, 1.0); } Future _saveAndNotify() async { await _save(); if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Saved'))); } @override Widget build(BuildContext context) { return PopScope( canPop: true, onPopInvokedWithResult: (didPop, _) { if (didPop && _isDirty) _save(); }, child: CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.keyZ, control: true): _undo, const SingleActivator(LogicalKeyboardKey.keyY, control: true): _redo, const SingleActivator( LogicalKeyboardKey.keyZ, control: true, shift: true, ): _redo, SingleActivator(LogicalKeyboardKey.keyS, control: true): _saveAndNotify, }, child: Focus( autofocus: true, child: Scaffold( appBar: AppBar( title: SizedBox( height: 40, child: TextField( controller: _titleController, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w600, ), decoration: InputDecoration( border: InputBorder.none, hintText: 'Note title...', contentPadding: const EdgeInsets.symmetric(vertical: 8), suffix: _isDirty ? const Text( ' •', style: TextStyle( fontWeight: FontWeight.bold, fontSize: 18, ), ) : null, ), onChanged: (value) { _title = value; setState(() => _isDirty = true); }, ), ), actions: [ IconButton( icon: const Icon(Icons.check), tooltip: 'Save', onPressed: _saveAndNotify, ), ], ), body: Column( children: [ 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, onZoomIn: _zoomIn, onZoomOut: _zoomOut, onZoomFitWidth: _zoomReset, zoomLabel: '${(_zoomLevel * 100).round()}%', ), Expanded( child: InteractiveViewer( transformationController: _zoomController, minScale: 0.5, maxScale: 5.0, child: InkCanvas( strokes: _undoManager.currentStrokes, onStrokeComplete: _onStrokeComplete, onErase: _onErase, tool: _currentTool, color: _currentColor, strokeWidth: _currentStrokeWidth, pressureCurve: _pressureCurve, stabilizationLevel: _stabilizationLevel, filled: _filled, ), ), ), ], ), ), ), ), ); } }