Files
BadNote/lib/widgets/annotation_toolbar.dart

434 lines
15 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_colorpicker/flutter_colorpicker.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../utils/stroke_stabilizer.dart';
import '../widgets/ink_canvas.dart';
import 'color_preset_bar.dart';
/// Shared annotation toolbar used by note editor, PDF annotator, and PPT annotator.
class AnnotationToolbar extends StatelessWidget {
final PenTool currentTool;
final Color currentColor;
final double currentStrokeWidth;
final bool filled;
final PressureCurveType pressureCurveType;
final StabilizationLevel stabilizationLevel;
final bool canUndo;
final bool canRedo;
final ValueChanged<PenTool> onToolChanged;
final ValueChanged<Color> onColorChanged;
final ValueChanged<double> onStrokeWidthChanged;
final ValueChanged<bool> onFilledChanged;
final ValueChanged<PressureCurveType> onPressureCurveChanged;
final ValueChanged<StabilizationLevel> onStabilizationChanged;
final VoidCallback? onUndo;
final VoidCallback? onRedo;
final VoidCallback? onPreviousPage;
final VoidCallback? onNextPage;
final String? pageInfo;
final InteractionMode interactionMode;
final ValueChanged<InteractionMode>? onInteractionModeChanged;
final double? zoomLevel;
final VoidCallback? onZoomIn;
final VoidCallback? onZoomOut;
final VoidCallback? onZoomFitWidth;
final String? zoomLabel;
const AnnotationToolbar({
super.key,
required this.currentTool,
required this.currentColor,
required this.currentStrokeWidth,
this.filled = false,
required this.pressureCurveType,
required this.stabilizationLevel,
required this.canUndo,
required this.canRedo,
required this.onToolChanged,
required this.onColorChanged,
required this.onStrokeWidthChanged,
required this.onFilledChanged,
required this.onPressureCurveChanged,
required this.onStabilizationChanged,
this.onUndo,
this.onRedo,
this.onPreviousPage,
this.onNextPage,
this.pageInfo,
this.interactionMode = InteractionMode.draw,
this.onInteractionModeChanged,
this.zoomLevel,
this.onZoomIn,
this.onZoomOut,
this.onZoomFitWidth,
this.zoomLabel,
});
static const _toolDefinitions = [
_ToolDef(PenTool.pen, Icons.edit, 'Pen'),
_ToolDef(PenTool.marker, Icons.highlight, 'Marker'),
_ToolDef(PenTool.highlighter, Icons.border_color, 'Highlighter'),
_ToolDef(PenTool.eraser, Icons.auto_fix_normal, 'Eraser'),
_ToolDef(PenTool.rectangle, Icons.rectangle_outlined, 'Rectangle'),
_ToolDef(PenTool.ellipse, Icons.circle_outlined, 'Ellipse'),
_ToolDef(PenTool.line, Icons.horizontal_rule, 'Line'),
_ToolDef(PenTool.arrow, Icons.arrow_right_alt, 'Arrow'),
_ToolDef(PenTool.text, Icons.text_fields, 'Text'),
];
bool get _isShapeTool {
return currentTool == PenTool.rectangle || currentTool == PenTool.ellipse;
}
void _showColorPicker(BuildContext context) {
showDialog(
context: context,
builder: (context) {
Color pickerColor = currentColor;
return AlertDialog(
title: const Text('Pick a color'),
content: SingleChildScrollView(
child: ColorPicker(
pickerColor: pickerColor,
onColorChanged: (color) => pickerColor = color,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
onColorChanged(pickerColor);
Navigator.of(context).pop();
},
child: const Text('OK'),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Row 1: Mode toggle + Tools + color presets + stroke width + undo/redo
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
// Pen/Navigate mode toggle
if (onInteractionModeChanged != null) ...[
Tooltip(
message: interactionMode == InteractionMode.draw
? 'Drawing mode'
: 'Navigate mode',
child: GestureDetector(
onTap: () {
final newMode = interactionMode == InteractionMode.draw
? InteractionMode.navigate
: InteractionMode.draw;
onInteractionModeChanged!(newMode);
},
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: interactionMode == InteractionMode.draw
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
interactionMode == InteractionMode.draw
? Icons.edit
: Icons.pan_tool,
size: 20,
color: interactionMode == InteractionMode.draw
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(
context,
).colorScheme.onTertiaryContainer,
),
),
),
),
const SizedBox(width: 6),
],
for (final def in _toolDefinitions) ...[
_ToolButton(
icon: def.icon,
label: def.label,
isSelected: currentTool == def.tool,
onPressed: () => onToolChanged(def.tool),
),
const SizedBox(width: 4),
],
// Filled toggle for shape tools
if (_isShapeTool) ...[
const SizedBox(width: 4),
Tooltip(
message: filled ? 'Filled' : 'Outline',
child: GestureDetector(
onTap: () => onFilledChanged(!filled),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: filled
? Theme.of(context).colorScheme.primaryContainer
: Colors.transparent,
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: filled
? Theme.of(context).colorScheme.primary
: Colors.grey.shade400,
),
),
child: Icon(
filled ? Icons.square : Icons.square_outlined,
size: 16,
color: filled
? Theme.of(context).colorScheme.onPrimaryContainer
: Colors.grey.shade600,
),
),
),
),
],
const SizedBox(width: 8),
ColorPresetBar(
selectedColor: currentColor,
onColorSelected: onColorChanged,
onOpenFullPicker: () => _showColorPicker(context),
),
const SizedBox(width: 8),
SizedBox(
width: 120,
child: Slider(
value: currentStrokeWidth,
min: 1.0,
max: 20.0,
divisions: 19,
label: currentStrokeWidth.toStringAsFixed(1),
onChanged: onStrokeWidthChanged,
),
),
IconButton(
icon: const Icon(Icons.undo),
tooltip: 'Undo',
iconSize: 20,
padding: const EdgeInsets.all(4),
onPressed: canUndo ? onUndo : null,
),
IconButton(
icon: const Icon(Icons.redo),
tooltip: 'Redo',
iconSize: 20,
padding: const EdgeInsets.all(4),
onPressed: canRedo ? onRedo : null,
),
// Page navigation (optional, for PDF/PPT)
if (onPreviousPage != null) ...[
IconButton(
icon: const Icon(Icons.navigate_before),
tooltip: 'Previous page',
iconSize: 20,
padding: const EdgeInsets.all(4),
onPressed: onPreviousPage,
),
if (pageInfo != null)
Text(pageInfo!, style: const TextStyle(fontSize: 12)),
IconButton(
icon: const Icon(Icons.navigate_next),
tooltip: 'Next page',
iconSize: 20,
padding: const EdgeInsets.all(4),
onPressed: onNextPage,
),
],
// Zoom controls (optional)
if (onZoomIn != null) ...[
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.zoom_out),
tooltip: 'Zoom out',
iconSize: 20,
padding: const EdgeInsets.all(4),
onPressed: onZoomOut,
),
if (zoomLabel != null)
Text(zoomLabel!, style: const TextStyle(fontSize: 11)),
IconButton(
icon: const Icon(Icons.zoom_in),
tooltip: 'Zoom in',
iconSize: 20,
padding: const EdgeInsets.all(4),
onPressed: onZoomIn,
),
if (onZoomFitWidth != null)
IconButton(
icon: const Icon(Icons.fit_screen),
tooltip: 'Fit to width',
iconSize: 20,
padding: const EdgeInsets.all(4),
onPressed: onZoomFitWidth,
),
],
],
),
),
// Row 2: Pressure curve + stabilization selectors
Row(
children: [
const Icon(Icons.touch_app, size: 14, color: Colors.grey),
const SizedBox(width: 4),
const Text(
'Pressure:',
style: TextStyle(fontSize: 11, color: Colors.grey),
),
const SizedBox(width: 4),
_buildSegmentedButton<PressureCurveType>(
context: context,
options: const {
PressureCurveType.linear: 'Lin',
PressureCurveType.soft: 'Soft',
PressureCurveType.hard: 'Hard',
},
selected: pressureCurveType,
onChanged: onPressureCurveChanged,
),
const SizedBox(width: 16),
const Icon(Icons.gesture, size: 14, color: Colors.grey),
const SizedBox(width: 4),
const Text(
'Smooth:',
style: TextStyle(fontSize: 11, color: Colors.grey),
),
const SizedBox(width: 4),
_buildSegmentedButton<StabilizationLevel>(
context: context,
options: const {
StabilizationLevel.none: 'Off',
StabilizationLevel.light: 'Low',
StabilizationLevel.medium: 'Med',
StabilizationLevel.heavy: 'High',
},
selected: stabilizationLevel,
onChanged: onStabilizationChanged,
),
],
),
],
),
);
}
Widget _buildSegmentedButton<T>({
required BuildContext context,
required Map<T, String> options,
required T selected,
required ValueChanged<T> onChanged,
}) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Theme.of(context).colorScheme.outline),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: options.entries.map((entry) {
final isSelected = entry.key == selected;
return GestureDetector(
onTap: () => onChanged(entry.key),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primaryContainer
: Colors.transparent,
borderRadius: BorderRadius.circular(5),
),
child: Text(
entry.value,
style: TextStyle(
fontSize: 11,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSurface,
),
),
),
);
}).toList(),
),
);
}
}
class _ToolDef {
final PenTool tool;
final IconData icon;
final String label;
const _ToolDef(this.tool, this.icon, this.label);
}
class _ToolButton extends StatelessWidget {
final IconData icon;
final String label;
final bool isSelected;
final VoidCallback onPressed;
const _ToolButton({
required this.icon,
required this.label,
required this.isSelected,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: label,
child: Material(
color: isSelected
? Theme.of(context).colorScheme.primaryContainer
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
icon,
size: 20,
color: isSelected
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSurface,
),
),
),
),
);
}
}