434 lines
15 KiB
Dart
434 lines
15 KiB
Dart
|
|
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,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|