Files
BadNote/lib/screens/note_editor_screen.dart

304 lines
9.1 KiB
Dart
Raw Normal View History

Fix bugs across app + server, optimize UI/UX, add Gitea CI Bug fixes (Flutter): - Wrap multi-statement DB writes (insert/update/delete note, deleteDocument, deletePageData, OCR FTS merge, migrations) in transactions to prevent data loss on interruption and a read-modify-write FTS race. - Fix PdfDocument leaks on exception (try/finally dispose) and preserve image aspect ratio when stamping images onto PDF pages. - Guard file-picker against empty selection (was .single -> crash). - Fix eraser ConcurrentModificationError and unmodifiable-list crash on PDF pages; capture page synchronously on save to stop wrong-page data loss. - Fix Riverpod DB-not-ready races, broken pull-to-refresh, settings load race, and search N+1; transform stored annotations on PDF page rotation. - Normalize pen pressure for devices without a pressure range. - PPT: single source of truth for slide strokes so ink displays and exports. UI/UX: - Material 3 typography, theme-aware colors (dark-mode fixes), hover cursors and right-click/visible actions on desktop, keyboard shortcuts (undo/redo/ save/find), toolbar overflow handling, friendlier empty states, semantic OCR status badges, relative timestamps, 1-based page indicators, large-deck PPT navigation, and a scratchpad-scope label in split view. Server (optional backend): - Persist JWT secret (was per-process random), block path traversal in storage, fix CORS '*'+credentials, add OCR job ownership checks, last-writer-wins sync guard, constant-time login, and split out heavy OCR deps so the API/tests run without them. CI: Gitea workflows for format+analyze+test (Linux, system sqlite) and a Windows release build; pristine `flutter analyze`, all Flutter and server tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 03:18:00 +08:00
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,
),
),
),
],
),
),
),
),
);
}
}