304 lines
9.1 KiB
Dart
304 lines
9.1 KiB
Dart
|
|
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<NoteEditorScreen> createState() => _NoteEditorScreenState();
|
||
|
|
}
|
||
|
|
|
||
|
|
class _NoteEditorScreenState extends ConsumerState<NoteEditorScreen> {
|
||
|
|
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<InkStroke> 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<void> _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<void> _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,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|