Fix bugs across app + server, optimize UI/UX, add Gitea CI
Some checks failed
CI / Test (Server, optional) (push) Failing after 2m10s
Windows Build / Build Windows (x64) (push) Failing after 29s
CI / Test (Flutter, Linux) (push) Has been cancelled
CI / Analyze (Flutter) (push) Has been cancelled

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:
2026-06-21 03:18:00 +08:00
commit 72428dc075
210 changed files with 18171 additions and 0 deletions

View 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,
),
),
),
),
);
}
}

View 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
View 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;
}
}

View 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,
),
),
),
],
),
),
);
},
),
);
}
}

View 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,
);
},
);
}
}