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>
This commit is contained in:
433
lib/widgets/annotation_toolbar.dart
Normal file
433
lib/widgets/annotation_toolbar.dart
Normal file
@@ -0,0 +1,433 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
75
lib/widgets/color_preset_bar.dart
Normal file
75
lib/widgets/color_preset_bar.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A row of preset color circles with a palette icon to open the full picker.
|
||||
class ColorPresetBar extends StatelessWidget {
|
||||
final Color selectedColor;
|
||||
final ValueChanged<Color> onColorSelected;
|
||||
final VoidCallback onOpenFullPicker;
|
||||
|
||||
const ColorPresetBar({
|
||||
super.key,
|
||||
required this.selectedColor,
|
||||
required this.onColorSelected,
|
||||
required this.onOpenFullPicker,
|
||||
});
|
||||
|
||||
static const List<Color> presetColors = [
|
||||
Colors.black,
|
||||
Color(0xFFE53935), // red
|
||||
Color(0xFF1E88E5), // blue
|
||||
Color(0xFF43A047), // green
|
||||
Color(0xFFFB8C00), // orange
|
||||
Color(0xFF8E24AA), // purple
|
||||
Color(0xFF6D4C41), // brown
|
||||
Colors.white,
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final color in presetColors) ...[
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: InkWell(
|
||||
onTap: () => onColorSelected(color),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
child: Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: selectedColor == color
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade400,
|
||||
width: selectedColor == color ? 2.5 : 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: InkWell(
|
||||
onTap: onOpenFullPicker,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
child: Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.grey.shade400, width: 1.5),
|
||||
),
|
||||
child: const Icon(Icons.palette, size: 14, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
709
lib/widgets/ink_canvas.dart
Normal file
709
lib/widgets/ink_canvas.dart
Normal file
@@ -0,0 +1,709 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/ink_point.dart';
|
||||
import '../models/ink_stroke.dart';
|
||||
import '../models/pen_tool.dart';
|
||||
import '../models/pointer_device_kind.dart';
|
||||
import '../models/pressure_curve.dart';
|
||||
import '../utils/stroke_stabilizer.dart';
|
||||
|
||||
/// Controls whether the canvas accepts drawing input or passes events through.
|
||||
enum InteractionMode { draw, navigate }
|
||||
|
||||
class InkCanvas extends StatefulWidget {
|
||||
final List<InkStroke> strokes;
|
||||
final void Function(InkStroke stroke)? onStrokeComplete;
|
||||
final void Function(String strokeId, List<InkStroke> replacements)? onErase;
|
||||
final PenTool tool;
|
||||
final Color color;
|
||||
final double strokeWidth;
|
||||
final PressureCurve pressureCurve;
|
||||
final StabilizationLevel stabilizationLevel;
|
||||
final bool filled;
|
||||
final InteractionMode interactionMode;
|
||||
final Rect? viewportBounds;
|
||||
|
||||
const InkCanvas({
|
||||
super.key,
|
||||
required this.strokes,
|
||||
this.onStrokeComplete,
|
||||
this.onErase,
|
||||
this.tool = PenTool.pen,
|
||||
this.color = Colors.black,
|
||||
this.strokeWidth = 2.0,
|
||||
this.pressureCurve = PressureCurve.linear,
|
||||
this.stabilizationLevel = StabilizationLevel.none,
|
||||
this.filled = false,
|
||||
this.interactionMode = InteractionMode.draw,
|
||||
this.viewportBounds,
|
||||
});
|
||||
|
||||
@override
|
||||
State<InkCanvas> createState() => _InkCanvasState();
|
||||
}
|
||||
|
||||
class _InkCanvasState extends State<InkCanvas> {
|
||||
final List<InkPoint> _currentPoints = [];
|
||||
bool _isDrawing = false;
|
||||
PenTool? _activeTool;
|
||||
StrokeStabilizer? _stabilizer;
|
||||
|
||||
/// Start point for shape tools.
|
||||
InkPoint? _shapeStart;
|
||||
|
||||
/// Whether the active tool is a shape tool (needs only 2 points).
|
||||
bool get _isShapeTool {
|
||||
final t = _activeTool ?? widget.tool;
|
||||
return t == PenTool.rectangle ||
|
||||
t == PenTool.ellipse ||
|
||||
t == PenTool.line ||
|
||||
t == PenTool.arrow;
|
||||
}
|
||||
|
||||
/// Whether the active tool is the text tool.
|
||||
bool get _isTextTool {
|
||||
return (_activeTool ?? widget.tool) == PenTool.text;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_stabilizer = StrokeStabilizer(level: widget.stabilizationLevel);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(InkCanvas oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.stabilizationLevel != widget.stabilizationLevel) {
|
||||
_stabilizer = StrokeStabilizer(level: widget.stabilizationLevel);
|
||||
}
|
||||
}
|
||||
|
||||
InputDeviceKind _mapKind(PointerDeviceKind kind) {
|
||||
switch (kind) {
|
||||
case PointerDeviceKind.touch:
|
||||
return InputDeviceKind.touch;
|
||||
case PointerDeviceKind.mouse:
|
||||
return InputDeviceKind.mouse;
|
||||
case PointerDeviceKind.stylus:
|
||||
return InputDeviceKind.stylus;
|
||||
case PointerDeviceKind.invertedStylus:
|
||||
return InputDeviceKind.invertedStylus;
|
||||
case PointerDeviceKind.trackpad:
|
||||
return InputDeviceKind.trackpad;
|
||||
case PointerDeviceKind.unknown:
|
||||
return InputDeviceKind.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
InkPoint _makePoint(PointerEvent event) {
|
||||
return InkPoint(
|
||||
x: event.localPosition.dx,
|
||||
y: event.localPosition.dy,
|
||||
pressure: event.pressure,
|
||||
tilt: event is PointerMoveEvent ? event.tilt : 0.0,
|
||||
timestamp: event.timeStamp.inMicroseconds,
|
||||
pointerDeviceKind: _mapKind(event.kind),
|
||||
);
|
||||
}
|
||||
|
||||
void _handlePointerDown(PointerDownEvent event) {
|
||||
if (event.kind == PointerDeviceKind.trackpad) return;
|
||||
|
||||
// In navigate mode, no drawing at all — pass all events through.
|
||||
if (widget.interactionMode == InteractionMode.navigate) return;
|
||||
|
||||
// In draw mode: stylus and mouse draw, touch passes through for scrolling.
|
||||
if (event.kind == PointerDeviceKind.touch) return;
|
||||
|
||||
_isDrawing = true;
|
||||
_activeTool = widget.tool;
|
||||
|
||||
if (event.kind == PointerDeviceKind.invertedStylus) {
|
||||
_activeTool = PenTool.eraser;
|
||||
}
|
||||
|
||||
final point = _makePoint(event);
|
||||
|
||||
if (_activeTool == PenTool.eraser) {
|
||||
_eraseAt(point);
|
||||
} else if (_isTextTool) {
|
||||
// Text tool: record position, handled on pointer up
|
||||
_shapeStart = point;
|
||||
} else if (_isShapeTool) {
|
||||
// Shape tool: record start point
|
||||
_shapeStart = point;
|
||||
_stabilizer?.reset();
|
||||
setState(() {
|
||||
_currentPoints.clear();
|
||||
_currentPoints.add(point);
|
||||
});
|
||||
} else {
|
||||
// Freehand tools (pen, marker, highlighter)
|
||||
_stabilizer?.reset();
|
||||
final smoothed = _stabilizer?.filter(point) ?? point;
|
||||
setState(() {
|
||||
_currentPoints.clear();
|
||||
_currentPoints.add(smoothed);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePointerMove(PointerMoveEvent event) {
|
||||
if (!_isDrawing) return;
|
||||
|
||||
final point = _makePoint(event);
|
||||
|
||||
if (_activeTool == PenTool.eraser) {
|
||||
_eraseAt(point);
|
||||
} else if (_isTextTool) {
|
||||
// No preview for text tool
|
||||
return;
|
||||
} else if (_isShapeTool) {
|
||||
// Shape preview: keep only start + current
|
||||
setState(() {
|
||||
if (_currentPoints.length >= 2) {
|
||||
_currentPoints[1] = point;
|
||||
} else {
|
||||
_currentPoints.add(point);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Freehand
|
||||
final smoothed = _stabilizer?.filter(point) ?? point;
|
||||
setState(() {
|
||||
_currentPoints.add(smoothed);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePointerUp(PointerUpEvent event) {
|
||||
if (!_isDrawing) return;
|
||||
_isDrawing = false;
|
||||
|
||||
final activeTool = _activeTool ?? widget.tool;
|
||||
|
||||
if (activeTool == PenTool.eraser) {
|
||||
// Nothing to finalize
|
||||
} else if (_isTextTool) {
|
||||
if (_shapeStart != null) {
|
||||
_showTextDialog(_shapeStart!);
|
||||
}
|
||||
} else if (_isShapeTool) {
|
||||
// Shape: finalize with start + end points
|
||||
if (_currentPoints.length >= 2) {
|
||||
final stroke = InkStroke(
|
||||
id: _generateId(),
|
||||
points: List.from(_currentPoints),
|
||||
tool: activeTool,
|
||||
color: _getColorForTool(activeTool).toARGB32(),
|
||||
strokeWidth: widget.strokeWidth,
|
||||
createdAt: DateTime.now(),
|
||||
filled: widget.filled,
|
||||
);
|
||||
widget.onStrokeComplete?.call(stroke);
|
||||
}
|
||||
} else if (_currentPoints.isNotEmpty) {
|
||||
// Freehand
|
||||
final stroke = InkStroke(
|
||||
id: _generateId(),
|
||||
points: List.from(_currentPoints),
|
||||
tool: activeTool,
|
||||
color: _getColorForTool(activeTool).toARGB32(),
|
||||
strokeWidth: activeTool == PenTool.highlighter
|
||||
? widget.strokeWidth * 3
|
||||
: widget.strokeWidth,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
widget.onStrokeComplete?.call(stroke);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_currentPoints.clear();
|
||||
_shapeStart = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _showTextDialog(InkPoint position) {
|
||||
final controller = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Add Text'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(hintText: 'Enter text...'),
|
||||
maxLines: null,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final text = controller.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
final stroke = InkStroke(
|
||||
id: _generateId(),
|
||||
points: [position],
|
||||
tool: PenTool.text,
|
||||
color: widget.color.toARGB32(),
|
||||
strokeWidth: widget.strokeWidth,
|
||||
createdAt: DateTime.now(),
|
||||
textContent: text,
|
||||
fontSize: widget.strokeWidth * 7,
|
||||
);
|
||||
widget.onStrokeComplete?.call(stroke);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _eraseAt(InkPoint point) {
|
||||
final eraserRadius = widget.strokeWidth * 3;
|
||||
|
||||
// Collect all (strokeId, replacements) pairs before invoking any callback,
|
||||
// to avoid ConcurrentModificationError when the parent's onErase triggers
|
||||
// a setState that mutates widget.strokes mid-iteration.
|
||||
final toErase = <(String, List<InkStroke>)>[];
|
||||
|
||||
for (final stroke in widget.strokes) {
|
||||
if (stroke.tool == PenTool.eraser) continue;
|
||||
|
||||
final erasedIndices = <int>{};
|
||||
for (int i = 0; i < stroke.points.length; i++) {
|
||||
final p = stroke.points[i];
|
||||
final dx = p.x - point.x;
|
||||
final dy = p.y - point.y;
|
||||
if (dx * dx + dy * dy < eraserRadius * eraserRadius) {
|
||||
erasedIndices.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (erasedIndices.isEmpty) continue;
|
||||
|
||||
toErase.add((stroke.id, _splitStroke(stroke, erasedIndices)));
|
||||
}
|
||||
|
||||
for (final (strokeId, replacements) in toErase) {
|
||||
widget.onErase?.call(strokeId, replacements);
|
||||
}
|
||||
}
|
||||
|
||||
List<InkStroke> _splitStroke(InkStroke stroke, Set<int> erasedIndices) {
|
||||
final segments = <List<InkPoint>>[];
|
||||
List<InkPoint> currentSegment = [];
|
||||
|
||||
for (int i = 0; i < stroke.points.length; i++) {
|
||||
if (erasedIndices.contains(i)) {
|
||||
if (currentSegment.isNotEmpty) {
|
||||
segments.add(currentSegment);
|
||||
currentSegment = [];
|
||||
}
|
||||
} else {
|
||||
currentSegment.add(stroke.points[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSegment.isNotEmpty) {
|
||||
segments.add(currentSegment);
|
||||
}
|
||||
|
||||
final replacements = <InkStroke>[];
|
||||
for (final segment in segments) {
|
||||
if (segment.length >= 2) {
|
||||
replacements.add(
|
||||
InkStroke(
|
||||
id: _generateId(),
|
||||
points: segment,
|
||||
tool: stroke.tool,
|
||||
color: stroke.color,
|
||||
strokeWidth: stroke.strokeWidth,
|
||||
createdAt: stroke.createdAt,
|
||||
filled: stroke.filled,
|
||||
textContent: stroke.textContent,
|
||||
fontSize: stroke.fontSize,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return replacements;
|
||||
}
|
||||
|
||||
Color _getColorForTool(PenTool tool) {
|
||||
switch (tool) {
|
||||
case PenTool.marker:
|
||||
return widget.color.withAlpha(77);
|
||||
case PenTool.highlighter:
|
||||
return const Color(0x80FFFF00);
|
||||
case PenTool.pen:
|
||||
case PenTool.eraser:
|
||||
case PenTool.rectangle:
|
||||
case PenTool.ellipse:
|
||||
case PenTool.line:
|
||||
case PenTool.arrow:
|
||||
case PenTool.text:
|
||||
return widget.color;
|
||||
}
|
||||
}
|
||||
|
||||
String _generateId() {
|
||||
return const Uuid().v4();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Listener(
|
||||
onPointerDown: _handlePointerDown,
|
||||
onPointerMove: _handlePointerMove,
|
||||
onPointerUp: _handlePointerUp,
|
||||
child: CustomPaint(
|
||||
painter: _InkPainter(
|
||||
strokes: widget.strokes,
|
||||
currentPoints: _currentPoints,
|
||||
currentTool: _activeTool ?? widget.tool,
|
||||
currentColor: _getColorForTool(_activeTool ?? widget.tool),
|
||||
currentStrokeWidth:
|
||||
(_activeTool ?? widget.tool) == PenTool.highlighter
|
||||
? widget.strokeWidth * 3
|
||||
: widget.strokeWidth,
|
||||
pressureCurve: widget.pressureCurve,
|
||||
filled: widget.filled,
|
||||
viewportBounds: widget.viewportBounds,
|
||||
),
|
||||
size: Size.infinite,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InkPainter extends CustomPainter {
|
||||
final List<InkStroke> strokes;
|
||||
final List<InkPoint> currentPoints;
|
||||
final PenTool currentTool;
|
||||
final Color currentColor;
|
||||
final double currentStrokeWidth;
|
||||
final PressureCurve pressureCurve;
|
||||
final bool filled;
|
||||
final Rect? viewportBounds;
|
||||
|
||||
_InkPainter({
|
||||
required this.strokes,
|
||||
required this.currentPoints,
|
||||
required this.currentTool,
|
||||
required this.currentColor,
|
||||
required this.currentStrokeWidth,
|
||||
required this.pressureCurve,
|
||||
required this.filled,
|
||||
this.viewportBounds,
|
||||
});
|
||||
|
||||
bool _strokeInViewport(InkStroke stroke, Rect viewport) {
|
||||
if (stroke.points.isEmpty) return false;
|
||||
double minX = double.infinity, minY = double.infinity;
|
||||
double maxX = double.negativeInfinity, maxY = double.negativeInfinity;
|
||||
for (final p in stroke.points) {
|
||||
if (p.x < minX) minX = p.x;
|
||||
if (p.y < minY) minY = p.y;
|
||||
if (p.x > maxX) maxX = p.x;
|
||||
if (p.y > maxY) maxY = p.y;
|
||||
}
|
||||
return viewport.overlaps(Rect.fromLTRB(minX, minY, maxX, maxY));
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
for (final stroke in strokes) {
|
||||
if (stroke.tool == PenTool.eraser) continue;
|
||||
if (viewportBounds != null &&
|
||||
!_strokeInViewport(stroke, viewportBounds!)) {
|
||||
continue;
|
||||
}
|
||||
_drawStroke(
|
||||
canvas,
|
||||
stroke.points,
|
||||
stroke.tool,
|
||||
Color(stroke.color),
|
||||
stroke.strokeWidth,
|
||||
true,
|
||||
stroke.filled,
|
||||
stroke.textContent,
|
||||
stroke.fontSize,
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPoints.isNotEmpty && currentTool != PenTool.eraser) {
|
||||
_drawStroke(
|
||||
canvas,
|
||||
currentPoints,
|
||||
currentTool,
|
||||
currentColor,
|
||||
currentStrokeWidth,
|
||||
false,
|
||||
filled,
|
||||
null,
|
||||
14.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _drawStroke(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
PenTool tool,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
bool isComplete,
|
||||
bool strokeFilled,
|
||||
String? textContent,
|
||||
double fontSize,
|
||||
) {
|
||||
if (points.isEmpty) return;
|
||||
|
||||
switch (tool) {
|
||||
case PenTool.pen:
|
||||
case PenTool.marker:
|
||||
case PenTool.highlighter:
|
||||
case PenTool.eraser:
|
||||
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
||||
break;
|
||||
case PenTool.rectangle:
|
||||
if (points.length < 2) {
|
||||
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
||||
} else {
|
||||
_drawRect(canvas, points, color, strokeWidth, strokeFilled);
|
||||
}
|
||||
break;
|
||||
case PenTool.ellipse:
|
||||
if (points.length < 2) {
|
||||
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
||||
} else {
|
||||
_drawOval(canvas, points, color, strokeWidth, strokeFilled);
|
||||
}
|
||||
break;
|
||||
case PenTool.line:
|
||||
if (points.length < 2) {
|
||||
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
||||
} else {
|
||||
_drawLine(canvas, points, color, strokeWidth);
|
||||
}
|
||||
break;
|
||||
case PenTool.arrow:
|
||||
if (points.length < 2) {
|
||||
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
||||
} else {
|
||||
_drawArrow(canvas, points, color, strokeWidth);
|
||||
}
|
||||
break;
|
||||
case PenTool.text:
|
||||
if (textContent != null && textContent.isNotEmpty) {
|
||||
_drawText(canvas, points, textContent, fontSize, color);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _drawFreehand(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
PenTool tool,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
bool isComplete,
|
||||
) {
|
||||
final pfPoints = points
|
||||
.map(
|
||||
(p) => pf.Point(
|
||||
p.x,
|
||||
p.y,
|
||||
pressureCurve.apply(p.pressure).clamp(0.0, 1.0),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final thinning = (tool == PenTool.marker || tool == PenTool.highlighter)
|
||||
? 0.0
|
||||
: 0.7;
|
||||
|
||||
final outlinePoints = pf.getStroke(
|
||||
pfPoints,
|
||||
size: strokeWidth,
|
||||
thinning: thinning,
|
||||
smoothing: 0.5,
|
||||
streamline: 0.5,
|
||||
taperStart: 0.0,
|
||||
taperEnd: 0.0,
|
||||
capStart: true,
|
||||
capEnd: true,
|
||||
simulatePressure: tool != PenTool.marker && tool != PenTool.highlighter,
|
||||
isComplete: isComplete,
|
||||
);
|
||||
|
||||
if (outlinePoints.isEmpty) return;
|
||||
|
||||
final path = Path();
|
||||
path.moveTo(outlinePoints[0].x, outlinePoints[0].y);
|
||||
|
||||
for (int i = 1; i < outlinePoints.length; i++) {
|
||||
path.lineTo(outlinePoints[i].x, outlinePoints[i].y);
|
||||
}
|
||||
path.close();
|
||||
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.fill
|
||||
..isAntiAlias = true;
|
||||
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
void _drawRect(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
bool strokeFilled,
|
||||
) {
|
||||
final rect = Rect.fromPoints(
|
||||
Offset(points[0].x, points[0].y),
|
||||
Offset(points[1].x, points[1].y),
|
||||
);
|
||||
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = strokeWidth
|
||||
..isAntiAlias = true
|
||||
..style = strokeFilled ? PaintingStyle.fill : PaintingStyle.stroke;
|
||||
|
||||
canvas.drawRect(rect, paint);
|
||||
}
|
||||
|
||||
void _drawOval(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
bool strokeFilled,
|
||||
) {
|
||||
final rect = Rect.fromPoints(
|
||||
Offset(points[0].x, points[0].y),
|
||||
Offset(points[1].x, points[1].y),
|
||||
);
|
||||
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = strokeWidth
|
||||
..isAntiAlias = true
|
||||
..style = strokeFilled ? PaintingStyle.fill : PaintingStyle.stroke;
|
||||
|
||||
canvas.drawOval(rect, paint);
|
||||
}
|
||||
|
||||
void _drawLine(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = strokeWidth
|
||||
..isAntiAlias = true
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
canvas.drawLine(
|
||||
Offset(points[0].x, points[0].y),
|
||||
Offset(points[1].x, points[1].y),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
|
||||
void _drawArrow(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
) {
|
||||
final p1 = Offset(points[0].x, points[0].y);
|
||||
final p2 = Offset(points[1].x, points[1].y);
|
||||
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = strokeWidth
|
||||
..isAntiAlias = true
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
// Main line
|
||||
canvas.drawLine(p1, p2, paint);
|
||||
|
||||
// Arrowhead
|
||||
final dx = p2.dx - p1.dx;
|
||||
final dy = p2.dy - p1.dy;
|
||||
final angle = atan2(dy, dx);
|
||||
final arrowLength = strokeWidth * 5;
|
||||
const arrowAngle = pi / 6; // 30 degrees
|
||||
|
||||
final arrowP1 = Offset(
|
||||
p2.dx - arrowLength * cos(angle - arrowAngle),
|
||||
p2.dy - arrowLength * sin(angle - arrowAngle),
|
||||
);
|
||||
final arrowP2 = Offset(
|
||||
p2.dx - arrowLength * cos(angle + arrowAngle),
|
||||
p2.dy - arrowLength * sin(angle + arrowAngle),
|
||||
);
|
||||
|
||||
canvas.drawLine(p2, arrowP1, paint);
|
||||
canvas.drawLine(p2, arrowP2, paint);
|
||||
}
|
||||
|
||||
void _drawText(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
String text,
|
||||
double fontSize,
|
||||
Color color,
|
||||
) {
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(color: color, fontSize: fontSize),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
textPainter.layout();
|
||||
textPainter.paint(canvas, Offset(points[0].x, points[0].y));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _InkPainter oldDelegate) {
|
||||
if (strokes.length != oldDelegate.strokes.length) return true;
|
||||
if (currentPoints.length != oldDelegate.currentPoints.length) return true;
|
||||
for (int i = 0; i < strokes.length; i++) {
|
||||
final a = strokes[i], b = oldDelegate.strokes[i];
|
||||
if (a.id != b.id ||
|
||||
a.color != b.color ||
|
||||
a.strokeWidth != b.strokeWidth ||
|
||||
a.tool != b.tool) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return currentTool != oldDelegate.currentTool;
|
||||
}
|
||||
}
|
||||
206
lib/widgets/page_thumbnail_sidebar.dart
Normal file
206
lib/widgets/page_thumbnail_sidebar.dart
Normal file
@@ -0,0 +1,206 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/thumbnail_service.dart';
|
||||
|
||||
/// Vertical sidebar showing page thumbnails for quick navigation.
|
||||
///
|
||||
/// Thumbnails are lazily generated and cached on disk. The current page is
|
||||
/// highlighted with a blue border, and bookmarked pages show a colored dot.
|
||||
class PageThumbnailSidebar extends StatefulWidget {
|
||||
final String documentId;
|
||||
final String filePath;
|
||||
final int pageCount;
|
||||
final int currentPage;
|
||||
final ValueChanged<int> onPageTap;
|
||||
final Set<int> bookmarkedPages;
|
||||
|
||||
const PageThumbnailSidebar({
|
||||
super.key,
|
||||
required this.documentId,
|
||||
required this.filePath,
|
||||
required this.pageCount,
|
||||
required this.currentPage,
|
||||
required this.onPageTap,
|
||||
this.bookmarkedPages = const {},
|
||||
});
|
||||
|
||||
@override
|
||||
State<PageThumbnailSidebar> createState() => _PageThumbnailSidebarState();
|
||||
}
|
||||
|
||||
class _PageThumbnailSidebarState extends State<PageThumbnailSidebar> {
|
||||
/// Cached thumbnail image data keyed by page index.
|
||||
final Map<int, ImageProvider> _cache = {};
|
||||
|
||||
/// Pages currently being generated (to avoid duplicate work).
|
||||
final Set<int> _loading = {};
|
||||
|
||||
/// Pages that permanently failed thumbnail generation (null result or throw).
|
||||
/// Skipped on subsequent rebuilds to avoid a retry storm.
|
||||
final Set<int> _failed = {};
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PageThumbnailSidebar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.documentId != widget.documentId) {
|
||||
_cache.clear();
|
||||
_loading.clear();
|
||||
_failed.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadThumbnail(int pageIndex) async {
|
||||
if (_cache.containsKey(pageIndex) ||
|
||||
_loading.contains(pageIndex) ||
|
||||
_failed.contains(pageIndex)) {
|
||||
return;
|
||||
}
|
||||
_loading.add(pageIndex);
|
||||
|
||||
try {
|
||||
// Check disk cache first.
|
||||
final cached = await ThumbnailService.getCached(
|
||||
widget.documentId,
|
||||
pageIndex,
|
||||
);
|
||||
if (cached != null && mounted) {
|
||||
setState(() {
|
||||
_cache[pageIndex] = FileImage(cached);
|
||||
});
|
||||
_loading.remove(pageIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate from the PDF.
|
||||
final bytes = await ThumbnailService.generate(
|
||||
widget.filePath,
|
||||
pageIndex,
|
||||
maxWidth: 160,
|
||||
);
|
||||
if (bytes != null) {
|
||||
await ThumbnailService.cacheThumbnail(
|
||||
widget.documentId,
|
||||
pageIndex,
|
||||
bytes,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_cache[pageIndex] = MemoryImage(bytes);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Null result means generation failed permanently for this page.
|
||||
_failed.add(pageIndex);
|
||||
}
|
||||
} catch (_) {
|
||||
// Any exception is treated as a permanent failure to avoid retry storms.
|
||||
_failed.add(pageIndex);
|
||||
} finally {
|
||||
_loading.remove(pageIndex);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
border: Border(
|
||||
right: BorderSide(color: Theme.of(context).dividerColor, width: 1),
|
||||
),
|
||||
),
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: widget.pageCount,
|
||||
itemBuilder: (context, index) {
|
||||
_loadThumbnail(index);
|
||||
final isCurrentPage = index == widget.currentPage;
|
||||
final isBookmarked = widget.bookmarkedPages.contains(index);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onPageTap(index),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isCurrentPage
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade400,
|
||||
width: isCurrentPage ? 2.5 : 1.0,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Thumbnail image or placeholder.
|
||||
AspectRatio(
|
||||
aspectRatio: 8.5 / 11, // US Letter-ish ratio
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: _cache.containsKey(index)
|
||||
? Image(image: _cache[index]!, fit: BoxFit.cover)
|
||||
: Container(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerLow,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Page number overlay.
|
||||
Positioned(
|
||||
bottom: 2,
|
||||
right: 2,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bookmark indicator.
|
||||
if (isBookmarked)
|
||||
Positioned(
|
||||
top: 2,
|
||||
left: 2,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
162
lib/widgets/pdf_annotation_layer.dart
Normal file
162
lib/widgets/pdf_annotation_layer.dart
Normal file
@@ -0,0 +1,162 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/ink_point.dart';
|
||||
import '../models/ink_stroke.dart';
|
||||
import '../models/pen_tool.dart';
|
||||
import '../widgets/ink_canvas.dart';
|
||||
|
||||
/// Transparent overlay widget positioned on top of the PDF viewer.
|
||||
///
|
||||
/// Reuses the existing [InkCanvas] widget for ink rendering.
|
||||
/// Coordinates are normalized to [0, 1] relative to the overlay size,
|
||||
/// enabling correct mapping to PDF page coordinates during export.
|
||||
class PdfAnnotationLayer extends StatefulWidget {
|
||||
final List<InkStroke> strokes;
|
||||
final void Function(InkStroke stroke)? onStrokeComplete;
|
||||
final void Function(String strokeId, List<InkStroke> replacements)? onErase;
|
||||
final PenTool tool;
|
||||
final Color color;
|
||||
final double strokeWidth;
|
||||
final bool filled;
|
||||
final InteractionMode interactionMode;
|
||||
final int rotation;
|
||||
|
||||
const PdfAnnotationLayer({
|
||||
super.key,
|
||||
required this.strokes,
|
||||
this.onStrokeComplete,
|
||||
this.onErase,
|
||||
this.tool = PenTool.pen,
|
||||
this.color = Colors.black,
|
||||
this.strokeWidth = 2.0,
|
||||
this.filled = false,
|
||||
this.interactionMode = InteractionMode.draw,
|
||||
this.rotation = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PdfAnnotationLayer> createState() => _PdfAnnotationLayerState();
|
||||
}
|
||||
|
||||
class _PdfAnnotationLayerState extends State<PdfAnnotationLayer> {
|
||||
Size _canvasSize = Size.zero;
|
||||
|
||||
/// Applies inverse rotation to normalized coordinates for rendering.
|
||||
/// Converts from stored (possibly rotated) coords back to display coords.
|
||||
Offset _inverseRotate(double nx, double ny, int rotation) {
|
||||
switch (rotation % 360) {
|
||||
case 90:
|
||||
return Offset(1.0 - ny, nx);
|
||||
case 180:
|
||||
return Offset(1.0 - nx, 1.0 - ny);
|
||||
case 270:
|
||||
return Offset(ny, 1.0 - nx);
|
||||
default:
|
||||
return Offset(nx, ny);
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies forward rotation to normalized coordinates before storage.
|
||||
/// Converts from display coords to the canonical rotated representation.
|
||||
Offset _forwardRotate(double nx, double ny, int rotation) {
|
||||
switch (rotation % 360) {
|
||||
case 90:
|
||||
return Offset(ny, 1.0 - nx);
|
||||
case 180:
|
||||
return Offset(1.0 - nx, 1.0 - ny);
|
||||
case 270:
|
||||
return Offset(1.0 - ny, nx);
|
||||
default:
|
||||
return Offset(nx, ny);
|
||||
}
|
||||
}
|
||||
|
||||
/// Scales a stroke's points from normalized [0, 1] coordinates to
|
||||
/// the current canvas pixel coordinates for rendering.
|
||||
/// Applies inverse rotation before scaling so strokes render correctly
|
||||
/// on a rotated page.
|
||||
List<InkStroke> get _scaledStrokes {
|
||||
if (_canvasSize == Size.zero) return widget.strokes;
|
||||
return widget.strokes.map((stroke) {
|
||||
return InkStroke(
|
||||
id: stroke.id,
|
||||
points: stroke.points.map((pt) {
|
||||
final rotated = _inverseRotate(pt.x, pt.y, widget.rotation);
|
||||
return InkPoint(
|
||||
x: rotated.dx * _canvasSize.width,
|
||||
y: rotated.dy * _canvasSize.height,
|
||||
pressure: pt.pressure,
|
||||
tilt: pt.tilt,
|
||||
timestamp: pt.timestamp,
|
||||
pointerDeviceKind: pt.pointerDeviceKind,
|
||||
);
|
||||
}).toList(),
|
||||
tool: stroke.tool,
|
||||
color: stroke.color,
|
||||
strokeWidth: stroke.strokeWidth,
|
||||
createdAt: stroke.createdAt,
|
||||
filled: stroke.filled,
|
||||
textContent: stroke.textContent,
|
||||
fontSize: stroke.fontSize,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Normalizes a stroke's points from canvas pixel coordinates to
|
||||
/// [0, 1] relative to the overlay size.
|
||||
/// Applies forward rotation before storage so the canonical representation
|
||||
/// accounts for the current page rotation.
|
||||
InkStroke _normalizeStroke(InkStroke stroke) {
|
||||
if (_canvasSize == Size.zero) return stroke;
|
||||
return InkStroke(
|
||||
id: stroke.id,
|
||||
points: stroke.points.map((pt) {
|
||||
final nx = pt.x / _canvasSize.width;
|
||||
final ny = pt.y / _canvasSize.height;
|
||||
final rotated = _forwardRotate(nx, ny, widget.rotation);
|
||||
return InkPoint(
|
||||
x: rotated.dx,
|
||||
y: rotated.dy,
|
||||
pressure: pt.pressure,
|
||||
tilt: pt.tilt,
|
||||
timestamp: pt.timestamp,
|
||||
pointerDeviceKind: pt.pointerDeviceKind,
|
||||
);
|
||||
}).toList(),
|
||||
tool: stroke.tool,
|
||||
color: stroke.color,
|
||||
strokeWidth: stroke.strokeWidth,
|
||||
createdAt: stroke.createdAt,
|
||||
filled: stroke.filled,
|
||||
textContent: stroke.textContent,
|
||||
fontSize: stroke.fontSize,
|
||||
);
|
||||
}
|
||||
|
||||
void _onStrokeComplete(InkStroke stroke) {
|
||||
widget.onStrokeComplete?.call(_normalizeStroke(stroke));
|
||||
}
|
||||
|
||||
void _onErase(String strokeId, List<InkStroke> replacements) {
|
||||
widget.onErase?.call(strokeId, replacements.map(_normalizeStroke).toList());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
_canvasSize = Size(constraints.maxWidth, constraints.maxHeight);
|
||||
return InkCanvas(
|
||||
strokes: _scaledStrokes,
|
||||
onStrokeComplete: _onStrokeComplete,
|
||||
onErase: _onErase,
|
||||
tool: widget.tool,
|
||||
color: widget.color,
|
||||
strokeWidth: widget.strokeWidth,
|
||||
filled: widget.filled,
|
||||
interactionMode: widget.interactionMode,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user