Compare commits
2 Commits
1e2a83b0b9
...
28b7af7e72
| Author | SHA1 | Date | |
|---|---|---|---|
| 28b7af7e72 | |||
| 914951afb7 |
@@ -8,9 +8,26 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:pdfrx/pdfrx.dart';
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
|
|
||||||
|
import '../../services/database_service.dart';
|
||||||
|
import '../engine/stroke_model.dart';
|
||||||
|
import '../persistence/editor_repository.dart';
|
||||||
|
import '../persistence/save_scheduler.dart';
|
||||||
import 'pen_canvas.dart';
|
import 'pen_canvas.dart';
|
||||||
import 'pen_stroke.dart';
|
import 'pen_stroke.dart';
|
||||||
|
|
||||||
|
/// Stable deterministic document-id for a file path (djb2 hash → hex).
|
||||||
|
///
|
||||||
|
/// Produces a fixed-length hex string from the path so the id is filesystem-
|
||||||
|
/// independent (no slashes, spaces, or non-ASCII characters) and stable across
|
||||||
|
/// restarts. Collisions are astronomically unlikely for a single-user app.
|
||||||
|
String _documentIdFromPath(String path) {
|
||||||
|
var hash = 5381;
|
||||||
|
for (final c in path.codeUnits) {
|
||||||
|
hash = ((hash << 5) + hash + c) & 0xFFFFFFFF;
|
||||||
|
}
|
||||||
|
return hash.toRadixString(16).padLeft(8, '0');
|
||||||
|
}
|
||||||
|
|
||||||
class PenEditorScreen extends StatefulWidget {
|
class PenEditorScreen extends StatefulWidget {
|
||||||
const PenEditorScreen({super.key, required this.pdfPath});
|
const PenEditorScreen({super.key, required this.pdfPath});
|
||||||
|
|
||||||
@@ -30,6 +47,13 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
/// Strokes per page, keyed by 0-based page index (normalized coords).
|
/// Strokes per page, keyed by 0-based page index (normalized coords).
|
||||||
final Map<int, List<PenStroke>> _strokesByPage = {};
|
final Map<int, List<PenStroke>> _strokesByPage = {};
|
||||||
|
|
||||||
|
// ── Persistence ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Stable document-id derived from the PDF file path.
|
||||||
|
late final String _documentId;
|
||||||
|
|
||||||
|
SaveScheduler? _saveScheduler;
|
||||||
|
|
||||||
/// One shared transform for the current page; recentred on page change so
|
/// One shared transform for the current page; recentred on page change so
|
||||||
/// each page opens fit-to-view and centered.
|
/// each page opens fit-to-view and centered.
|
||||||
final TransformationController _transform = TransformationController();
|
final TransformationController _transform = TransformationController();
|
||||||
@@ -69,9 +93,64 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_documentId = _documentIdFromPath(widget.pdfPath);
|
||||||
|
_initPersistence();
|
||||||
_open();
|
_open();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _initPersistence() async {
|
||||||
|
final service = await DatabaseService.getInstance();
|
||||||
|
if (!mounted) return;
|
||||||
|
final repo = await EditorRepository.fromService(service);
|
||||||
|
final scheduler = SaveScheduler(repo);
|
||||||
|
if (!mounted) {
|
||||||
|
scheduler.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_saveScheduler = scheduler;
|
||||||
|
// Load any previously persisted strokes for this document.
|
||||||
|
await _loadPersistedStrokes(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load all persisted strokes for [_documentId] and populate [_strokesByPage].
|
||||||
|
Future<void> _loadPersistedStrokes(EditorRepository repo) async {
|
||||||
|
final hosted = await repo.loadDocument(_documentId);
|
||||||
|
if (!mounted) return;
|
||||||
|
final loaded = <int, List<PenStroke>>{};
|
||||||
|
for (final entry in hosted.entries) {
|
||||||
|
final pageIndex = _pageIndexFromHostId(entry.key);
|
||||||
|
if (pageIndex == null) continue;
|
||||||
|
loaded[pageIndex] = entry.value
|
||||||
|
.map((es) => PenStroke(
|
||||||
|
points: es.points
|
||||||
|
.map((ep) => PenPoint(ep.x, ep.y, ep.pressure))
|
||||||
|
.toList(),
|
||||||
|
color: es.color,
|
||||||
|
width: es.width,
|
||||||
|
kind: es.tool == EditorTool.highlighter
|
||||||
|
? PenStrokeKind.highlighter
|
||||||
|
: PenStrokeKind.pen,
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
if (loaded.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
for (final entry in loaded.entries) {
|
||||||
|
_strokesByPage[entry.key] = entry.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the page index from a host_id of the form
|
||||||
|
/// `"doc:<documentId>:page:<pageIndex>"`.
|
||||||
|
int? _pageIndexFromHostId(String hostId) {
|
||||||
|
const marker = ':page:';
|
||||||
|
final idx = hostId.lastIndexOf(marker);
|
||||||
|
if (idx == -1) return null;
|
||||||
|
return int.tryParse(hostId.substring(idx + marker.length));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _open() async {
|
Future<void> _open() async {
|
||||||
try {
|
try {
|
||||||
final doc = await PdfDocument.openFile(widget.pdfPath);
|
final doc = await PdfDocument.openFile(widget.pdfPath);
|
||||||
@@ -87,6 +166,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
// Flush any pending scheduled saves before tearing down.
|
||||||
|
final scheduler = _saveScheduler;
|
||||||
|
if (scheduler != null) {
|
||||||
|
scheduler.flush(); // fire-and-forget; DB write continues in isolate
|
||||||
|
scheduler.dispose();
|
||||||
|
}
|
||||||
_document?.dispose();
|
_document?.dispose();
|
||||||
_transform.dispose();
|
_transform.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -105,6 +190,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
stroke,
|
stroke,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
// Snapshot SYNCHRONOUSLY (before any await) then schedule persistence.
|
||||||
|
final snapshot = List<PenStroke>.of(_strokesByPage[_pageIndex]!);
|
||||||
|
_schedulePageSave(_pageIndex, snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _eraseStroke(int index) {
|
void _eraseStroke(int index) {
|
||||||
@@ -115,6 +203,27 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
_strokesByPage[_pageIndex] = next;
|
_strokesByPage[_pageIndex] = next;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Snapshot SYNCHRONOUSLY after the mutation, then schedule persistence.
|
||||||
|
final current = _strokesByPage[_pageIndex];
|
||||||
|
final snapshot =
|
||||||
|
current != null ? List<PenStroke>.of(current) : <PenStroke>[];
|
||||||
|
_schedulePageSave(_pageIndex, snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert [strokes] to [EditorStroke]s and hand them to the save scheduler.
|
||||||
|
///
|
||||||
|
/// Must be called synchronously (no await between the snapshot and this call)
|
||||||
|
/// so the scheduler receives an immutable copy of the in-memory state.
|
||||||
|
void _schedulePageSave(int pageIndex, List<PenStroke> strokes) {
|
||||||
|
final scheduler = _saveScheduler;
|
||||||
|
if (scheduler == null) return;
|
||||||
|
final editorStrokes =
|
||||||
|
strokes.map((s) => EditorStroke.fromPenStroke(s)).toList();
|
||||||
|
scheduler.schedule(
|
||||||
|
'page',
|
||||||
|
EditorRepository.pageHostId(_documentId, pageIndex),
|
||||||
|
editorStrokes,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _goToPage(int index) {
|
void _goToPage(int index) {
|
||||||
|
|||||||
68
lib/editor/engine/stroke_geometry.dart
Normal file
68
lib/editor/engine/stroke_geometry.dart
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// lib/editor/engine/stroke_geometry.dart
|
||||||
|
//
|
||||||
|
// Single source of stroke outline geometry for both screen render and export.
|
||||||
|
// The recipe is lifted verbatim from the proven live
|
||||||
|
// `ink_painters.buildStrokePath` (lib/editor/canvas/ink_painters.dart): points
|
||||||
|
// are scaled from normalized page coords to pixels, perfect_freehand produces
|
||||||
|
// the outline, and a closed fill Path is built. Keeping ONE implementation here
|
||||||
|
// kills the hairline-export divergence (R7).
|
||||||
|
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
||||||
|
|
||||||
|
import 'stroke_model.dart';
|
||||||
|
|
||||||
|
/// Builds a closed, fillable outline [Path] for one [stroke], scaled into the
|
||||||
|
/// pixel space of [pageSize] (which maps normalized [0,1] coords to pixels).
|
||||||
|
///
|
||||||
|
/// [isComplete] should be false for the in-progress live stroke so freehand
|
||||||
|
/// tapers the trailing end correctly, and true for committed strokes.
|
||||||
|
///
|
||||||
|
/// Returns an empty [Path] when the stroke has no points (or freehand produces
|
||||||
|
/// no outline).
|
||||||
|
Path buildStrokeOutline(
|
||||||
|
EditorStroke stroke,
|
||||||
|
Size pageSize, {
|
||||||
|
required bool isComplete,
|
||||||
|
}) {
|
||||||
|
final path = Path();
|
||||||
|
if (stroke.points.isEmpty) return path;
|
||||||
|
|
||||||
|
final pixelWidth = stroke.width * pageSize.width;
|
||||||
|
|
||||||
|
final hasRealPressure = stroke.points.any((p) => p.pressure != null);
|
||||||
|
final isHighlighter = stroke.tool == EditorTool.highlighter;
|
||||||
|
|
||||||
|
final pfPoints = stroke.points
|
||||||
|
.map(
|
||||||
|
(p) => pf.Point(
|
||||||
|
p.x * pageSize.width,
|
||||||
|
p.y * pageSize.height,
|
||||||
|
p.pressure ?? 0.5,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final outline = pf.getStroke(
|
||||||
|
pfPoints,
|
||||||
|
size: pixelWidth,
|
||||||
|
// Highlighter keeps a constant width (no thinning); pen thins (0.7),
|
||||||
|
// matching the live recipe.
|
||||||
|
thinning: isHighlighter ? 0.0 : 0.7,
|
||||||
|
smoothing: 0.5,
|
||||||
|
streamline: 0.5,
|
||||||
|
// Real stylus pressure -> don't simulate; no pressure -> let freehand fake
|
||||||
|
// it based on velocity (highlighter never simulates).
|
||||||
|
simulatePressure: !hasRealPressure && !isHighlighter,
|
||||||
|
isComplete: isComplete,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (outline.isEmpty) return path;
|
||||||
|
path.moveTo(outline.first.x, outline.first.y);
|
||||||
|
for (var i = 1; i < outline.length; i++) {
|
||||||
|
path.lineTo(outline[i].x, outline[i].y);
|
||||||
|
}
|
||||||
|
path.close();
|
||||||
|
return path;
|
||||||
|
}
|
||||||
182
lib/editor/engine/stroke_model.dart
Normal file
182
lib/editor/engine/stroke_model.dart
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
// lib/editor/engine/stroke_model.dart
|
||||||
|
//
|
||||||
|
// Canonical, persistable stroke model for the BadNote editor engine.
|
||||||
|
//
|
||||||
|
// This is the single source of truth for ink strokes across the new own-canvas
|
||||||
|
// engine (screen render + export + persistence). It is a deliberate SUPERSET of
|
||||||
|
// both the in-memory live `PenStroke`/`PenPoint` (lib/editor/canvas/pen_stroke.dart)
|
||||||
|
// and the freezed/JSON `InkStroke`/`InkPoint` (lib/models/ink_stroke.dart) so the
|
||||||
|
// adapters below round-trip losslessly with `InkStroke` (SF1): `tilt`,
|
||||||
|
// `timestamp` and `pointerDeviceKind` are preserved, never dropped.
|
||||||
|
//
|
||||||
|
// Coordinate semantics (matching the live conventions):
|
||||||
|
// * Point x/y are NORMALIZED to the page rectangle, i.e. in [0,1].
|
||||||
|
// * Stroke `width` is a FRACTION of the page width, so it scales with zoom.
|
||||||
|
|
||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
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 '../canvas/pen_stroke.dart';
|
||||||
|
|
||||||
|
part 'stroke_model.freezed.dart';
|
||||||
|
part 'stroke_model.g.dart';
|
||||||
|
|
||||||
|
const _uuid = Uuid();
|
||||||
|
|
||||||
|
/// The drawing tools the engine knows about. Extensible; P0 uses these three.
|
||||||
|
enum EditorTool {
|
||||||
|
@JsonValue('pen')
|
||||||
|
pen,
|
||||||
|
@JsonValue('highlighter')
|
||||||
|
highlighter,
|
||||||
|
@JsonValue('eraser')
|
||||||
|
eraser,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single captured sample of a stroke.
|
||||||
|
///
|
||||||
|
/// [x]/[y] are normalized to the page rectangle ([0,1]). The remaining fields
|
||||||
|
/// are a superset of [InkPoint] (nullable here so the live capture path can
|
||||||
|
/// leave them unset, while [InkStroke] data round-trips intact through the
|
||||||
|
/// adapters below).
|
||||||
|
@freezed
|
||||||
|
abstract class EditorPoint with _$EditorPoint {
|
||||||
|
const factory EditorPoint({
|
||||||
|
required double x,
|
||||||
|
required double y,
|
||||||
|
double? pressure,
|
||||||
|
double? tilt,
|
||||||
|
int? timestamp,
|
||||||
|
InputDeviceKind? pointerDeviceKind,
|
||||||
|
}) = _EditorPoint;
|
||||||
|
|
||||||
|
factory EditorPoint.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$EditorPointFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A committed stroke in normalized page coordinates.
|
||||||
|
///
|
||||||
|
/// [width] is a fraction of page width (matches live `PenStroke.width`).
|
||||||
|
@freezed
|
||||||
|
abstract class EditorStroke with _$EditorStroke {
|
||||||
|
const EditorStroke._();
|
||||||
|
|
||||||
|
factory EditorStroke({
|
||||||
|
required String id,
|
||||||
|
required List<EditorPoint> points,
|
||||||
|
@Default(EditorTool.pen) EditorTool tool,
|
||||||
|
@Default(0xFF000000) int color,
|
||||||
|
@Default(0.003) double width,
|
||||||
|
@Default(false) bool filled,
|
||||||
|
String? textContent,
|
||||||
|
@Default(14.0) double fontSize,
|
||||||
|
}) = _EditorStroke;
|
||||||
|
|
||||||
|
/// Convenience constructor that generates a uuid [id] when none is supplied.
|
||||||
|
factory EditorStroke.create({
|
||||||
|
String? id,
|
||||||
|
required List<EditorPoint> points,
|
||||||
|
EditorTool tool = EditorTool.pen,
|
||||||
|
int color = 0xFF000000,
|
||||||
|
double width = 0.003,
|
||||||
|
bool filled = false,
|
||||||
|
String? textContent,
|
||||||
|
double fontSize = 14.0,
|
||||||
|
}) =>
|
||||||
|
EditorStroke(
|
||||||
|
id: id ?? _uuid.v4(),
|
||||||
|
points: points,
|
||||||
|
tool: tool,
|
||||||
|
color: color,
|
||||||
|
width: width,
|
||||||
|
filled: filled,
|
||||||
|
textContent: textContent,
|
||||||
|
fontSize: fontSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
factory EditorStroke.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$EditorStrokeFromJson(json);
|
||||||
|
|
||||||
|
// ---- Adapters -----------------------------------------------------------
|
||||||
|
|
||||||
|
/// Adapts an in-memory live [PenStroke] (normalized, no tilt/timestamp/kind).
|
||||||
|
factory EditorStroke.fromPenStroke(PenStroke stroke, {String? id}) =>
|
||||||
|
EditorStroke(
|
||||||
|
id: id ?? _uuid.v4(),
|
||||||
|
points: stroke.points
|
||||||
|
.map((p) => EditorPoint(x: p.x, y: p.y, pressure: p.pressure))
|
||||||
|
.toList(),
|
||||||
|
tool: switch (stroke.kind) {
|
||||||
|
PenStrokeKind.pen => EditorTool.pen,
|
||||||
|
PenStrokeKind.highlighter => EditorTool.highlighter,
|
||||||
|
},
|
||||||
|
color: stroke.color,
|
||||||
|
width: stroke.width,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Lossless adapter from the freezed/JSON [InkStroke] model.
|
||||||
|
factory EditorStroke.fromInkStroke(InkStroke stroke) => EditorStroke(
|
||||||
|
id: stroke.id,
|
||||||
|
points: stroke.points
|
||||||
|
.map(
|
||||||
|
(p) => EditorPoint(
|
||||||
|
x: p.x,
|
||||||
|
y: p.y,
|
||||||
|
pressure: p.pressure,
|
||||||
|
tilt: p.tilt,
|
||||||
|
timestamp: p.timestamp,
|
||||||
|
pointerDeviceKind: p.pointerDeviceKind,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
tool: _toolFromPenTool(stroke.tool),
|
||||||
|
color: stroke.color,
|
||||||
|
width: stroke.strokeWidth,
|
||||||
|
filled: stroke.filled,
|
||||||
|
textContent: stroke.textContent,
|
||||||
|
fontSize: stroke.fontSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Lossless adapter to the freezed/JSON [InkStroke] model. Null superset
|
||||||
|
/// fields fall back to [InkPoint]'s own defaults so the InkStroke round-trip
|
||||||
|
/// (fromInkStroke → toInkStroke) reproduces the original exactly.
|
||||||
|
InkStroke toInkStroke({DateTime? createdAt}) => InkStroke(
|
||||||
|
id: id,
|
||||||
|
points: points
|
||||||
|
.map(
|
||||||
|
(p) => InkPoint(
|
||||||
|
x: p.x,
|
||||||
|
y: p.y,
|
||||||
|
pressure: p.pressure ?? 0.5,
|
||||||
|
tilt: p.tilt ?? 0.0,
|
||||||
|
timestamp: p.timestamp ?? 0,
|
||||||
|
pointerDeviceKind:
|
||||||
|
p.pointerDeviceKind ?? InputDeviceKind.unknown,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
tool: _toolToPenTool(tool),
|
||||||
|
color: color,
|
||||||
|
strokeWidth: width,
|
||||||
|
createdAt: createdAt ?? DateTime.fromMillisecondsSinceEpoch(0),
|
||||||
|
filled: filled,
|
||||||
|
textContent: textContent,
|
||||||
|
fontSize: fontSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
static EditorTool _toolFromPenTool(PenTool tool) => switch (tool) {
|
||||||
|
PenTool.highlighter => EditorTool.highlighter,
|
||||||
|
PenTool.eraser => EditorTool.eraser,
|
||||||
|
_ => EditorTool.pen,
|
||||||
|
};
|
||||||
|
|
||||||
|
static PenTool _toolToPenTool(EditorTool tool) => switch (tool) {
|
||||||
|
EditorTool.pen => PenTool.pen,
|
||||||
|
EditorTool.highlighter => PenTool.highlighter,
|
||||||
|
EditorTool.eraser => PenTool.eraser,
|
||||||
|
};
|
||||||
|
}
|
||||||
618
lib/editor/engine/stroke_model.freezed.dart
Normal file
618
lib/editor/engine/stroke_model.freezed.dart
Normal file
@@ -0,0 +1,618 @@
|
|||||||
|
// coverage:ignore-file
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||||
|
|
||||||
|
part of 'stroke_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// FreezedGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
T _$identity<T>(T value) => value;
|
||||||
|
|
||||||
|
final _privateConstructorUsedError = UnsupportedError(
|
||||||
|
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
|
||||||
|
);
|
||||||
|
|
||||||
|
EditorPoint _$EditorPointFromJson(Map<String, dynamic> json) {
|
||||||
|
return _EditorPoint.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$EditorPoint {
|
||||||
|
double get x => throw _privateConstructorUsedError;
|
||||||
|
double get y => throw _privateConstructorUsedError;
|
||||||
|
double? get pressure => throw _privateConstructorUsedError;
|
||||||
|
double? get tilt => throw _privateConstructorUsedError;
|
||||||
|
int? get timestamp => throw _privateConstructorUsedError;
|
||||||
|
InputDeviceKind? get pointerDeviceKind => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// Serializes this EditorPoint to a JSON map.
|
||||||
|
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// Create a copy of EditorPoint
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
$EditorPointCopyWith<EditorPoint> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class $EditorPointCopyWith<$Res> {
|
||||||
|
factory $EditorPointCopyWith(
|
||||||
|
EditorPoint value,
|
||||||
|
$Res Function(EditorPoint) then,
|
||||||
|
) = _$EditorPointCopyWithImpl<$Res, EditorPoint>;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
double x,
|
||||||
|
double y,
|
||||||
|
double? pressure,
|
||||||
|
double? tilt,
|
||||||
|
int? timestamp,
|
||||||
|
InputDeviceKind? pointerDeviceKind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class _$EditorPointCopyWithImpl<$Res, $Val extends EditorPoint>
|
||||||
|
implements $EditorPointCopyWith<$Res> {
|
||||||
|
_$EditorPointCopyWithImpl(this._value, this._then);
|
||||||
|
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Val _value;
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Res Function($Val) _then;
|
||||||
|
|
||||||
|
/// Create a copy of EditorPoint
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? x = null,
|
||||||
|
Object? y = null,
|
||||||
|
Object? pressure = freezed,
|
||||||
|
Object? tilt = freezed,
|
||||||
|
Object? timestamp = freezed,
|
||||||
|
Object? pointerDeviceKind = freezed,
|
||||||
|
}) {
|
||||||
|
return _then(
|
||||||
|
_value.copyWith(
|
||||||
|
x: null == x
|
||||||
|
? _value.x
|
||||||
|
: x // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double,
|
||||||
|
y: null == y
|
||||||
|
? _value.y
|
||||||
|
: y // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double,
|
||||||
|
pressure: freezed == pressure
|
||||||
|
? _value.pressure
|
||||||
|
: pressure // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double?,
|
||||||
|
tilt: freezed == tilt
|
||||||
|
? _value.tilt
|
||||||
|
: tilt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double?,
|
||||||
|
timestamp: freezed == timestamp
|
||||||
|
? _value.timestamp
|
||||||
|
: timestamp // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int?,
|
||||||
|
pointerDeviceKind: freezed == pointerDeviceKind
|
||||||
|
? _value.pointerDeviceKind
|
||||||
|
: pointerDeviceKind // ignore: cast_nullable_to_non_nullable
|
||||||
|
as InputDeviceKind?,
|
||||||
|
)
|
||||||
|
as $Val,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class _$$EditorPointImplCopyWith<$Res>
|
||||||
|
implements $EditorPointCopyWith<$Res> {
|
||||||
|
factory _$$EditorPointImplCopyWith(
|
||||||
|
_$EditorPointImpl value,
|
||||||
|
$Res Function(_$EditorPointImpl) then,
|
||||||
|
) = __$$EditorPointImplCopyWithImpl<$Res>;
|
||||||
|
@override
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
double x,
|
||||||
|
double y,
|
||||||
|
double? pressure,
|
||||||
|
double? tilt,
|
||||||
|
int? timestamp,
|
||||||
|
InputDeviceKind? pointerDeviceKind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class __$$EditorPointImplCopyWithImpl<$Res>
|
||||||
|
extends _$EditorPointCopyWithImpl<$Res, _$EditorPointImpl>
|
||||||
|
implements _$$EditorPointImplCopyWith<$Res> {
|
||||||
|
__$$EditorPointImplCopyWithImpl(
|
||||||
|
_$EditorPointImpl _value,
|
||||||
|
$Res Function(_$EditorPointImpl) _then,
|
||||||
|
) : super(_value, _then);
|
||||||
|
|
||||||
|
/// Create a copy of EditorPoint
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? x = null,
|
||||||
|
Object? y = null,
|
||||||
|
Object? pressure = freezed,
|
||||||
|
Object? tilt = freezed,
|
||||||
|
Object? timestamp = freezed,
|
||||||
|
Object? pointerDeviceKind = freezed,
|
||||||
|
}) {
|
||||||
|
return _then(
|
||||||
|
_$EditorPointImpl(
|
||||||
|
x: null == x
|
||||||
|
? _value.x
|
||||||
|
: x // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double,
|
||||||
|
y: null == y
|
||||||
|
? _value.y
|
||||||
|
: y // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double,
|
||||||
|
pressure: freezed == pressure
|
||||||
|
? _value.pressure
|
||||||
|
: pressure // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double?,
|
||||||
|
tilt: freezed == tilt
|
||||||
|
? _value.tilt
|
||||||
|
: tilt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double?,
|
||||||
|
timestamp: freezed == timestamp
|
||||||
|
? _value.timestamp
|
||||||
|
: timestamp // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int?,
|
||||||
|
pointerDeviceKind: freezed == pointerDeviceKind
|
||||||
|
? _value.pointerDeviceKind
|
||||||
|
: pointerDeviceKind // ignore: cast_nullable_to_non_nullable
|
||||||
|
as InputDeviceKind?,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
@JsonSerializable()
|
||||||
|
class _$EditorPointImpl implements _EditorPoint {
|
||||||
|
const _$EditorPointImpl({
|
||||||
|
required this.x,
|
||||||
|
required this.y,
|
||||||
|
this.pressure,
|
||||||
|
this.tilt,
|
||||||
|
this.timestamp,
|
||||||
|
this.pointerDeviceKind,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory _$EditorPointImpl.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$$EditorPointImplFromJson(json);
|
||||||
|
|
||||||
|
@override
|
||||||
|
final double x;
|
||||||
|
@override
|
||||||
|
final double y;
|
||||||
|
@override
|
||||||
|
final double? pressure;
|
||||||
|
@override
|
||||||
|
final double? tilt;
|
||||||
|
@override
|
||||||
|
final int? timestamp;
|
||||||
|
@override
|
||||||
|
final InputDeviceKind? pointerDeviceKind;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'EditorPoint(x: $x, y: $y, pressure: $pressure, tilt: $tilt, timestamp: $timestamp, pointerDeviceKind: $pointerDeviceKind)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
(other.runtimeType == runtimeType &&
|
||||||
|
other is _$EditorPointImpl &&
|
||||||
|
(identical(other.x, x) || other.x == x) &&
|
||||||
|
(identical(other.y, y) || other.y == y) &&
|
||||||
|
(identical(other.pressure, pressure) ||
|
||||||
|
other.pressure == pressure) &&
|
||||||
|
(identical(other.tilt, tilt) || other.tilt == tilt) &&
|
||||||
|
(identical(other.timestamp, timestamp) ||
|
||||||
|
other.timestamp == timestamp) &&
|
||||||
|
(identical(other.pointerDeviceKind, pointerDeviceKind) ||
|
||||||
|
other.pointerDeviceKind == pointerDeviceKind));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(
|
||||||
|
runtimeType,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
pressure,
|
||||||
|
tilt,
|
||||||
|
timestamp,
|
||||||
|
pointerDeviceKind,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Create a copy of EditorPoint
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$$EditorPointImplCopyWith<_$EditorPointImpl> get copyWith =>
|
||||||
|
__$$EditorPointImplCopyWithImpl<_$EditorPointImpl>(this, _$identity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return _$$EditorPointImplToJson(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _EditorPoint implements EditorPoint {
|
||||||
|
const factory _EditorPoint({
|
||||||
|
required final double x,
|
||||||
|
required final double y,
|
||||||
|
final double? pressure,
|
||||||
|
final double? tilt,
|
||||||
|
final int? timestamp,
|
||||||
|
final InputDeviceKind? pointerDeviceKind,
|
||||||
|
}) = _$EditorPointImpl;
|
||||||
|
|
||||||
|
factory _EditorPoint.fromJson(Map<String, dynamic> json) =
|
||||||
|
_$EditorPointImpl.fromJson;
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get x;
|
||||||
|
@override
|
||||||
|
double get y;
|
||||||
|
@override
|
||||||
|
double? get pressure;
|
||||||
|
@override
|
||||||
|
double? get tilt;
|
||||||
|
@override
|
||||||
|
int? get timestamp;
|
||||||
|
@override
|
||||||
|
InputDeviceKind? get pointerDeviceKind;
|
||||||
|
|
||||||
|
/// Create a copy of EditorPoint
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
_$$EditorPointImplCopyWith<_$EditorPointImpl> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorStroke _$EditorStrokeFromJson(Map<String, dynamic> json) {
|
||||||
|
return _EditorStroke.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$EditorStroke {
|
||||||
|
String get id => throw _privateConstructorUsedError;
|
||||||
|
List<EditorPoint> get points => throw _privateConstructorUsedError;
|
||||||
|
EditorTool get tool => throw _privateConstructorUsedError;
|
||||||
|
int get color => throw _privateConstructorUsedError;
|
||||||
|
double get width => throw _privateConstructorUsedError;
|
||||||
|
bool get filled => throw _privateConstructorUsedError;
|
||||||
|
String? get textContent => throw _privateConstructorUsedError;
|
||||||
|
double get fontSize => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// Serializes this EditorStroke to a JSON map.
|
||||||
|
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// Create a copy of EditorStroke
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
$EditorStrokeCopyWith<EditorStroke> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class $EditorStrokeCopyWith<$Res> {
|
||||||
|
factory $EditorStrokeCopyWith(
|
||||||
|
EditorStroke value,
|
||||||
|
$Res Function(EditorStroke) then,
|
||||||
|
) = _$EditorStrokeCopyWithImpl<$Res, EditorStroke>;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
String id,
|
||||||
|
List<EditorPoint> points,
|
||||||
|
EditorTool tool,
|
||||||
|
int color,
|
||||||
|
double width,
|
||||||
|
bool filled,
|
||||||
|
String? textContent,
|
||||||
|
double fontSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class _$EditorStrokeCopyWithImpl<$Res, $Val extends EditorStroke>
|
||||||
|
implements $EditorStrokeCopyWith<$Res> {
|
||||||
|
_$EditorStrokeCopyWithImpl(this._value, this._then);
|
||||||
|
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Val _value;
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Res Function($Val) _then;
|
||||||
|
|
||||||
|
/// Create a copy of EditorStroke
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? id = null,
|
||||||
|
Object? points = null,
|
||||||
|
Object? tool = null,
|
||||||
|
Object? color = null,
|
||||||
|
Object? width = null,
|
||||||
|
Object? filled = null,
|
||||||
|
Object? textContent = freezed,
|
||||||
|
Object? fontSize = null,
|
||||||
|
}) {
|
||||||
|
return _then(
|
||||||
|
_value.copyWith(
|
||||||
|
id: null == id
|
||||||
|
? _value.id
|
||||||
|
: id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
points: null == points
|
||||||
|
? _value.points
|
||||||
|
: points // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<EditorPoint>,
|
||||||
|
tool: null == tool
|
||||||
|
? _value.tool
|
||||||
|
: tool // ignore: cast_nullable_to_non_nullable
|
||||||
|
as EditorTool,
|
||||||
|
color: null == color
|
||||||
|
? _value.color
|
||||||
|
: color // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
width: null == width
|
||||||
|
? _value.width
|
||||||
|
: width // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double,
|
||||||
|
filled: null == filled
|
||||||
|
? _value.filled
|
||||||
|
: filled // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
textContent: freezed == textContent
|
||||||
|
? _value.textContent
|
||||||
|
: textContent // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
fontSize: null == fontSize
|
||||||
|
? _value.fontSize
|
||||||
|
: fontSize // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double,
|
||||||
|
)
|
||||||
|
as $Val,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class _$$EditorStrokeImplCopyWith<$Res>
|
||||||
|
implements $EditorStrokeCopyWith<$Res> {
|
||||||
|
factory _$$EditorStrokeImplCopyWith(
|
||||||
|
_$EditorStrokeImpl value,
|
||||||
|
$Res Function(_$EditorStrokeImpl) then,
|
||||||
|
) = __$$EditorStrokeImplCopyWithImpl<$Res>;
|
||||||
|
@override
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
String id,
|
||||||
|
List<EditorPoint> points,
|
||||||
|
EditorTool tool,
|
||||||
|
int color,
|
||||||
|
double width,
|
||||||
|
bool filled,
|
||||||
|
String? textContent,
|
||||||
|
double fontSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class __$$EditorStrokeImplCopyWithImpl<$Res>
|
||||||
|
extends _$EditorStrokeCopyWithImpl<$Res, _$EditorStrokeImpl>
|
||||||
|
implements _$$EditorStrokeImplCopyWith<$Res> {
|
||||||
|
__$$EditorStrokeImplCopyWithImpl(
|
||||||
|
_$EditorStrokeImpl _value,
|
||||||
|
$Res Function(_$EditorStrokeImpl) _then,
|
||||||
|
) : super(_value, _then);
|
||||||
|
|
||||||
|
/// Create a copy of EditorStroke
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? id = null,
|
||||||
|
Object? points = null,
|
||||||
|
Object? tool = null,
|
||||||
|
Object? color = null,
|
||||||
|
Object? width = null,
|
||||||
|
Object? filled = null,
|
||||||
|
Object? textContent = freezed,
|
||||||
|
Object? fontSize = null,
|
||||||
|
}) {
|
||||||
|
return _then(
|
||||||
|
_$EditorStrokeImpl(
|
||||||
|
id: null == id
|
||||||
|
? _value.id
|
||||||
|
: id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
points: null == points
|
||||||
|
? _value._points
|
||||||
|
: points // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<EditorPoint>,
|
||||||
|
tool: null == tool
|
||||||
|
? _value.tool
|
||||||
|
: tool // ignore: cast_nullable_to_non_nullable
|
||||||
|
as EditorTool,
|
||||||
|
color: null == color
|
||||||
|
? _value.color
|
||||||
|
: color // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
width: null == width
|
||||||
|
? _value.width
|
||||||
|
: width // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double,
|
||||||
|
filled: null == filled
|
||||||
|
? _value.filled
|
||||||
|
: filled // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
textContent: freezed == textContent
|
||||||
|
? _value.textContent
|
||||||
|
: textContent // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
fontSize: null == fontSize
|
||||||
|
? _value.fontSize
|
||||||
|
: fontSize // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
@JsonSerializable()
|
||||||
|
class _$EditorStrokeImpl extends _EditorStroke {
|
||||||
|
_$EditorStrokeImpl({
|
||||||
|
required this.id,
|
||||||
|
required final List<EditorPoint> points,
|
||||||
|
this.tool = EditorTool.pen,
|
||||||
|
this.color = 0xFF000000,
|
||||||
|
this.width = 0.003,
|
||||||
|
this.filled = false,
|
||||||
|
this.textContent,
|
||||||
|
this.fontSize = 14.0,
|
||||||
|
}) : _points = points,
|
||||||
|
super._();
|
||||||
|
|
||||||
|
factory _$EditorStrokeImpl.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$$EditorStrokeImplFromJson(json);
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String id;
|
||||||
|
final List<EditorPoint> _points;
|
||||||
|
@override
|
||||||
|
List<EditorPoint> get points {
|
||||||
|
if (_points is EqualUnmodifiableListView) return _points;
|
||||||
|
// ignore: implicit_dynamic_type
|
||||||
|
return EqualUnmodifiableListView(_points);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final EditorTool tool;
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final int color;
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final double width;
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final bool filled;
|
||||||
|
@override
|
||||||
|
final String? textContent;
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final double fontSize;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'EditorStroke(id: $id, points: $points, tool: $tool, color: $color, width: $width, filled: $filled, textContent: $textContent, fontSize: $fontSize)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
(other.runtimeType == runtimeType &&
|
||||||
|
other is _$EditorStrokeImpl &&
|
||||||
|
(identical(other.id, id) || other.id == id) &&
|
||||||
|
const DeepCollectionEquality().equals(other._points, _points) &&
|
||||||
|
(identical(other.tool, tool) || other.tool == tool) &&
|
||||||
|
(identical(other.color, color) || other.color == color) &&
|
||||||
|
(identical(other.width, width) || other.width == width) &&
|
||||||
|
(identical(other.filled, filled) || other.filled == filled) &&
|
||||||
|
(identical(other.textContent, textContent) ||
|
||||||
|
other.textContent == textContent) &&
|
||||||
|
(identical(other.fontSize, fontSize) ||
|
||||||
|
other.fontSize == fontSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(
|
||||||
|
runtimeType,
|
||||||
|
id,
|
||||||
|
const DeepCollectionEquality().hash(_points),
|
||||||
|
tool,
|
||||||
|
color,
|
||||||
|
width,
|
||||||
|
filled,
|
||||||
|
textContent,
|
||||||
|
fontSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Create a copy of EditorStroke
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$$EditorStrokeImplCopyWith<_$EditorStrokeImpl> get copyWith =>
|
||||||
|
__$$EditorStrokeImplCopyWithImpl<_$EditorStrokeImpl>(this, _$identity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return _$$EditorStrokeImplToJson(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _EditorStroke extends EditorStroke {
|
||||||
|
factory _EditorStroke({
|
||||||
|
required final String id,
|
||||||
|
required final List<EditorPoint> points,
|
||||||
|
final EditorTool tool,
|
||||||
|
final int color,
|
||||||
|
final double width,
|
||||||
|
final bool filled,
|
||||||
|
final String? textContent,
|
||||||
|
final double fontSize,
|
||||||
|
}) = _$EditorStrokeImpl;
|
||||||
|
_EditorStroke._() : super._();
|
||||||
|
|
||||||
|
factory _EditorStroke.fromJson(Map<String, dynamic> json) =
|
||||||
|
_$EditorStrokeImpl.fromJson;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get id;
|
||||||
|
@override
|
||||||
|
List<EditorPoint> get points;
|
||||||
|
@override
|
||||||
|
EditorTool get tool;
|
||||||
|
@override
|
||||||
|
int get color;
|
||||||
|
@override
|
||||||
|
double get width;
|
||||||
|
@override
|
||||||
|
bool get filled;
|
||||||
|
@override
|
||||||
|
String? get textContent;
|
||||||
|
@override
|
||||||
|
double get fontSize;
|
||||||
|
|
||||||
|
/// Create a copy of EditorStroke
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
_$$EditorStrokeImplCopyWith<_$EditorStrokeImpl> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
73
lib/editor/engine/stroke_model.g.dart
Normal file
73
lib/editor/engine/stroke_model.g.dart
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'stroke_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
_$EditorPointImpl _$$EditorPointImplFromJson(Map<String, dynamic> json) =>
|
||||||
|
_$EditorPointImpl(
|
||||||
|
x: (json['x'] as num).toDouble(),
|
||||||
|
y: (json['y'] as num).toDouble(),
|
||||||
|
pressure: (json['pressure'] as num?)?.toDouble(),
|
||||||
|
tilt: (json['tilt'] as num?)?.toDouble(),
|
||||||
|
timestamp: (json['timestamp'] as num?)?.toInt(),
|
||||||
|
pointerDeviceKind: $enumDecodeNullable(
|
||||||
|
_$InputDeviceKindEnumMap,
|
||||||
|
json['pointerDeviceKind'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$EditorPointImplToJson(_$EditorPointImpl instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'x': instance.x,
|
||||||
|
'y': instance.y,
|
||||||
|
'pressure': instance.pressure,
|
||||||
|
'tilt': instance.tilt,
|
||||||
|
'timestamp': instance.timestamp,
|
||||||
|
'pointerDeviceKind': _$InputDeviceKindEnumMap[instance.pointerDeviceKind],
|
||||||
|
};
|
||||||
|
|
||||||
|
const _$InputDeviceKindEnumMap = {
|
||||||
|
InputDeviceKind.touch: 'touch',
|
||||||
|
InputDeviceKind.mouse: 'mouse',
|
||||||
|
InputDeviceKind.stylus: 'stylus',
|
||||||
|
InputDeviceKind.invertedStylus: 'invertedStylus',
|
||||||
|
InputDeviceKind.trackpad: 'trackpad',
|
||||||
|
InputDeviceKind.unknown: 'unknown',
|
||||||
|
};
|
||||||
|
|
||||||
|
_$EditorStrokeImpl _$$EditorStrokeImplFromJson(Map<String, dynamic> json) =>
|
||||||
|
_$EditorStrokeImpl(
|
||||||
|
id: json['id'] as String,
|
||||||
|
points: (json['points'] as List<dynamic>)
|
||||||
|
.map((e) => EditorPoint.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
tool:
|
||||||
|
$enumDecodeNullable(_$EditorToolEnumMap, json['tool']) ??
|
||||||
|
EditorTool.pen,
|
||||||
|
color: (json['color'] as num?)?.toInt() ?? 0xFF000000,
|
||||||
|
width: (json['width'] as num?)?.toDouble() ?? 0.003,
|
||||||
|
filled: json['filled'] as bool? ?? false,
|
||||||
|
textContent: json['textContent'] as String?,
|
||||||
|
fontSize: (json['fontSize'] as num?)?.toDouble() ?? 14.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$EditorStrokeImplToJson(_$EditorStrokeImpl instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'points': instance.points,
|
||||||
|
'tool': _$EditorToolEnumMap[instance.tool]!,
|
||||||
|
'color': instance.color,
|
||||||
|
'width': instance.width,
|
||||||
|
'filled': instance.filled,
|
||||||
|
'textContent': instance.textContent,
|
||||||
|
'fontSize': instance.fontSize,
|
||||||
|
};
|
||||||
|
|
||||||
|
const _$EditorToolEnumMap = {
|
||||||
|
EditorTool.pen: 'pen',
|
||||||
|
EditorTool.highlighter: 'highlighter',
|
||||||
|
EditorTool.eraser: 'eraser',
|
||||||
|
};
|
||||||
56
lib/editor/engine/stroke_store.dart
Normal file
56
lib/editor/engine/stroke_store.dart
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// lib/editor/engine/stroke_store.dart
|
||||||
|
//
|
||||||
|
// Mutable, revision-tracked store for committed EditorStrokes.
|
||||||
|
//
|
||||||
|
// Every mutation bumps [revision] (monotonic int). Consumers use the revision
|
||||||
|
// as an O(1) repaint gate: if revision has not changed since the last paint,
|
||||||
|
// nothing needs to be redrawn (StaticInkPainter.shouldRepaint).
|
||||||
|
|
||||||
|
import 'stroke_model.dart';
|
||||||
|
|
||||||
|
/// Holds the ordered list of committed [EditorStroke]s for one ink host (e.g.
|
||||||
|
/// a page or annotation layer). Every mutating operation bumps [revision].
|
||||||
|
///
|
||||||
|
/// This class is intentionally NOT a ChangeNotifier / Listenable — callers
|
||||||
|
/// poll the revision number from within CustomPainter.shouldRepaint, so no
|
||||||
|
/// subscription machinery is needed here.
|
||||||
|
class StrokeStore {
|
||||||
|
final List<EditorStroke> _strokes = [];
|
||||||
|
int _revision = 0;
|
||||||
|
|
||||||
|
/// Monotonically increasing counter. Bumped on every mutation.
|
||||||
|
int get revision => _revision;
|
||||||
|
|
||||||
|
/// Unmodifiable ordered list of committed strokes.
|
||||||
|
List<EditorStroke> get committed => List.unmodifiable(_strokes);
|
||||||
|
|
||||||
|
/// Appends [stroke] and bumps the revision.
|
||||||
|
void add(EditorStroke stroke) {
|
||||||
|
_strokes.add(stroke);
|
||||||
|
_revision++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the stroke with the given [id] (no-op if not found) and bumps
|
||||||
|
/// the revision only when a stroke was actually removed.
|
||||||
|
void removeById(String id) {
|
||||||
|
final before = _strokes.length;
|
||||||
|
_strokes.removeWhere((s) => s.id == id);
|
||||||
|
if (_strokes.length != before) {
|
||||||
|
_revision++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the entire stroke list and bumps the revision.
|
||||||
|
void replaceAll(List<EditorStroke> strokes) {
|
||||||
|
_strokes
|
||||||
|
..clear()
|
||||||
|
..addAll(strokes);
|
||||||
|
_revision++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all strokes and bumps the revision.
|
||||||
|
void clear() {
|
||||||
|
_strokes.clear();
|
||||||
|
_revision++;
|
||||||
|
}
|
||||||
|
}
|
||||||
149
lib/editor/persistence/editor_repository.dart
Normal file
149
lib/editor/persistence/editor_repository.dart
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
// lib/editor/persistence/editor_repository.dart
|
||||||
|
//
|
||||||
|
// MF3 diff-write contract: per-host diff of stroke ids against the last
|
||||||
|
// persisted set. Only changed/new rows are upserted; only removed rows are
|
||||||
|
// deleted. All mutations run in ONE transaction per saveHost call.
|
||||||
|
// The in-memory _persistedIds map is updated only after the transaction
|
||||||
|
// commits successfully.
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
|
||||||
|
import '../engine/stroke_model.dart';
|
||||||
|
import '../../services/database_service.dart';
|
||||||
|
|
||||||
|
/// Repository for persisting [EditorStroke]s to the `ink` table.
|
||||||
|
///
|
||||||
|
/// Host-id scheme: `"page:<pageIndex>"` where pageIndex is the zero-based
|
||||||
|
/// index of the page within its document. For example, page 0 of a document
|
||||||
|
/// uses host_id `"page:0"`.
|
||||||
|
///
|
||||||
|
/// [loadDocument] uses a single batched query over all ink rows whose
|
||||||
|
/// host_id begins with `"page:"` for the document, grouped by host_id.
|
||||||
|
/// [saveHost] implements the MF3 diff-write contract.
|
||||||
|
class EditorRepository {
|
||||||
|
EditorRepository(this._db);
|
||||||
|
|
||||||
|
final Database _db;
|
||||||
|
|
||||||
|
/// Per host_id, the set of stroke ids that were last persisted to the DB.
|
||||||
|
/// Updated only after a successful transaction commit.
|
||||||
|
final Map<String, Set<String>> _persistedIds = {};
|
||||||
|
|
||||||
|
// ── Factory ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Convenience constructor that initialises from [DatabaseService].
|
||||||
|
static Future<EditorRepository> fromService(DatabaseService service) async {
|
||||||
|
return EditorRepository(service.database);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Load all ink rows for [documentId] in a single batched query.
|
||||||
|
///
|
||||||
|
/// Returns a map keyed by host_id (e.g. `"page:0"`) whose values are
|
||||||
|
/// the strokes for that host in ascending ordinal order.
|
||||||
|
///
|
||||||
|
/// The host_id scheme is: host_kind = `"page"`, host_id = `"page:<i>"`.
|
||||||
|
Future<Map<String, List<EditorStroke>>> loadDocument(
|
||||||
|
String documentId,
|
||||||
|
) async {
|
||||||
|
// All page hosts for a document share the prefix "page:" inside host_id.
|
||||||
|
// We tag them with document_id via the host_id prefix convention:
|
||||||
|
// host_id = "doc:<documentId>:page:<pageIndex>"
|
||||||
|
final rows = await _db.query(
|
||||||
|
'ink',
|
||||||
|
where: 'host_kind = ? AND host_id LIKE ?',
|
||||||
|
whereArgs: ['page', 'doc:$documentId:page:%'],
|
||||||
|
orderBy: 'host_id ASC, ordinal ASC',
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = <String, List<EditorStroke>>{};
|
||||||
|
|
||||||
|
for (final row in rows) {
|
||||||
|
final hostId = row['host_id'] as String;
|
||||||
|
final strokeJson =
|
||||||
|
jsonDecode(row['stroke_json'] as String) as Map<String, dynamic>;
|
||||||
|
final stroke = EditorStroke.fromJson(strokeJson);
|
||||||
|
|
||||||
|
result.putIfAbsent(hostId, () => []).add(stroke);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate _persistedIds from what we just read so that subsequent
|
||||||
|
// saveHost calls can diff correctly even on a fresh repository instance.
|
||||||
|
for (final entry in result.entries) {
|
||||||
|
_persistedIds[entry.key] = entry.value.map((s) => s.id).toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Save (MF3 diff-write contract) ────────────────────────────────────
|
||||||
|
|
||||||
|
/// Persist [strokes] for the given host ([hostKind], [hostId]).
|
||||||
|
///
|
||||||
|
/// Diff against the last-known persisted id-set:
|
||||||
|
/// - NEW / CHANGED rows → INSERT OR REPLACE (upsert)
|
||||||
|
/// - REMOVED rows → DELETE
|
||||||
|
///
|
||||||
|
/// All mutations execute in a single transaction. [_persistedIds] is
|
||||||
|
/// updated only after the transaction commits.
|
||||||
|
Future<void> saveHost(
|
||||||
|
String hostKind,
|
||||||
|
String hostId,
|
||||||
|
List<EditorStroke> strokes,
|
||||||
|
) async {
|
||||||
|
final incoming = strokes;
|
||||||
|
final incomingIds = incoming.map((s) => s.id).toSet();
|
||||||
|
final persisted = _persistedIds[hostId] ?? {};
|
||||||
|
|
||||||
|
final toDelete = persisted.difference(incomingIds);
|
||||||
|
final toUpsert =
|
||||||
|
incoming.where((s) => !persisted.contains(s.id)).toList();
|
||||||
|
|
||||||
|
// Fast path: nothing to do.
|
||||||
|
if (toDelete.isEmpty && toUpsert.isEmpty) return;
|
||||||
|
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
|
||||||
|
await _db.transaction((txn) async {
|
||||||
|
// Upsert new/changed rows.
|
||||||
|
for (var i = 0; i < incoming.length; i++) {
|
||||||
|
final stroke = incoming[i];
|
||||||
|
if (!persisted.contains(stroke.id)) {
|
||||||
|
await txn.rawInsert(
|
||||||
|
'''INSERT INTO ink (id, host_kind, host_id, stroke_json, ordinal, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
stroke_json = excluded.stroke_json,
|
||||||
|
ordinal = excluded.ordinal,
|
||||||
|
updated_at = excluded.updated_at''',
|
||||||
|
[
|
||||||
|
stroke.id,
|
||||||
|
hostKind,
|
||||||
|
hostId,
|
||||||
|
jsonEncode(stroke.toJson()),
|
||||||
|
i,
|
||||||
|
now,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removed rows.
|
||||||
|
for (final id in toDelete) {
|
||||||
|
await txn.delete('ink', where: 'id = ?', whereArgs: [id]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update persisted id-set only after successful commit.
|
||||||
|
_persistedIds[hostId] = Set<String>.from(incomingIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Host-id helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Build the canonical host_id for a document page.
|
||||||
|
static String pageHostId(String documentId, int pageIndex) =>
|
||||||
|
'doc:$documentId:page:$pageIndex';
|
||||||
|
}
|
||||||
98
lib/editor/persistence/save_scheduler.dart
Normal file
98
lib/editor/persistence/save_scheduler.dart
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// lib/editor/persistence/save_scheduler.dart
|
||||||
|
//
|
||||||
|
// Debounced save scheduler. The snapshot/JSON is captured SYNCHRONOUSLY by
|
||||||
|
// the schedule() call before any await, so callers do not need to worry about
|
||||||
|
// the in-memory state mutating between schedule() and the actual write.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import '../engine/stroke_model.dart';
|
||||||
|
import 'editor_repository.dart';
|
||||||
|
|
||||||
|
/// Debounced save scheduler that batches rapid successive changes to a host
|
||||||
|
/// into a single [EditorRepository.saveHost] call.
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// ```dart
|
||||||
|
/// final scheduler = SaveScheduler(repository);
|
||||||
|
/// scheduler.schedule('page', hostId, List.from(strokes));
|
||||||
|
/// // …later, on dispose / navigate away:
|
||||||
|
/// await scheduler.flush();
|
||||||
|
/// scheduler.dispose();
|
||||||
|
/// ```
|
||||||
|
class SaveScheduler {
|
||||||
|
SaveScheduler(
|
||||||
|
this._repository, {
|
||||||
|
Duration debounce = const Duration(milliseconds: 800),
|
||||||
|
}) : _debounce = debounce;
|
||||||
|
|
||||||
|
final EditorRepository _repository;
|
||||||
|
final Duration _debounce;
|
||||||
|
|
||||||
|
// One pending timer + captured snapshot per host.
|
||||||
|
final Map<String, Timer> _timers = {};
|
||||||
|
final Map<String, _PendingWrite> _pending = {};
|
||||||
|
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
// ── Public API ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Schedule a save for ([hostKind], [hostId]).
|
||||||
|
///
|
||||||
|
/// [strokes] is captured synchronously (defensive copy via the caller's
|
||||||
|
/// `List.from(…)` convention or equivalent) so mutations after this call
|
||||||
|
/// do not affect what is written.
|
||||||
|
void schedule(
|
||||||
|
String hostKind,
|
||||||
|
String hostId,
|
||||||
|
List<EditorStroke> strokes,
|
||||||
|
) {
|
||||||
|
if (_disposed) return;
|
||||||
|
|
||||||
|
// Capture the snapshot synchronously before any async gap.
|
||||||
|
_pending[hostId] = _PendingWrite(hostKind: hostKind, strokes: strokes);
|
||||||
|
|
||||||
|
_timers[hostId]?.cancel();
|
||||||
|
_timers[hostId] = Timer(_debounce, () => _fire(hostId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force-write all pending saves immediately and wait for them to complete.
|
||||||
|
Future<void> flush() async {
|
||||||
|
final hosts = List<String>.from(_pending.keys);
|
||||||
|
for (final hostId in hosts) {
|
||||||
|
_timers[hostId]?.cancel();
|
||||||
|
_timers.remove(hostId);
|
||||||
|
await _fire(hostId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel all pending timers and release resources.
|
||||||
|
///
|
||||||
|
/// Call [flush] first if you need pending writes to complete.
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
for (final timer in _timers.values) {
|
||||||
|
timer.cancel();
|
||||||
|
}
|
||||||
|
_timers.clear();
|
||||||
|
_pending.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> _fire(String hostId) async {
|
||||||
|
final write = _pending.remove(hostId);
|
||||||
|
_timers.remove(hostId);
|
||||||
|
if (write == null) return;
|
||||||
|
await _repository.saveHost(write.hostKind, hostId, write.strokes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _PendingWrite {
|
||||||
|
const _PendingWrite({required this.hostKind, required this.strokes});
|
||||||
|
|
||||||
|
final String hostKind;
|
||||||
|
final List<EditorStroke> strokes;
|
||||||
|
}
|
||||||
71
lib/editor/render/annotation_layer.dart
Normal file
71
lib/editor/render/annotation_layer.dart
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// lib/editor/render/annotation_layer.dart
|
||||||
|
//
|
||||||
|
// Composites the static committed-stroke layer and the live in-progress layer
|
||||||
|
// into a single widget. Wrap the page widget with this to get ink rendering.
|
||||||
|
//
|
||||||
|
// Layout:
|
||||||
|
// RepaintBoundary
|
||||||
|
// └─ Stack
|
||||||
|
// ├─ CustomPaint(StaticInkPainter) ← repaints only on revision bump
|
||||||
|
// └─ CustomPaint(LiveInkPainter) ← repaints on every pointer move
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../engine/stroke_model.dart';
|
||||||
|
import '../engine/stroke_store.dart';
|
||||||
|
import 'ink_picture_cache.dart';
|
||||||
|
import 'live_ink_painter.dart';
|
||||||
|
import 'static_ink_painter.dart';
|
||||||
|
|
||||||
|
/// A [StatelessWidget] that renders committed and live ink strokes over a
|
||||||
|
/// [pageSize]-sized area.
|
||||||
|
///
|
||||||
|
/// Place it as an overlay on top of the page content; it is fully transparent
|
||||||
|
/// where no strokes are drawn.
|
||||||
|
///
|
||||||
|
/// [hostId] identifies the ink host (e.g. page id) and is used as the cache
|
||||||
|
/// key prefix so multiple pages can share an [InkPictureCache] instance.
|
||||||
|
class AnnotationLayer extends StatelessWidget {
|
||||||
|
const AnnotationLayer({
|
||||||
|
super.key,
|
||||||
|
required this.hostId,
|
||||||
|
required this.store,
|
||||||
|
required this.liveStroke,
|
||||||
|
required this.pageSize,
|
||||||
|
required this.cache,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String hostId;
|
||||||
|
final StrokeStore store;
|
||||||
|
|
||||||
|
/// The stroke currently being drawn, or null when idle.
|
||||||
|
final EditorStroke? liveStroke;
|
||||||
|
final Size pageSize;
|
||||||
|
final InkPictureCache cache;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
CustomPaint(
|
||||||
|
size: pageSize,
|
||||||
|
painter: StaticInkPainter(
|
||||||
|
hostId: hostId,
|
||||||
|
store: store,
|
||||||
|
pageSize: pageSize,
|
||||||
|
cache: cache,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
CustomPaint(
|
||||||
|
size: pageSize,
|
||||||
|
painter: LiveInkPainter(
|
||||||
|
live: liveStroke,
|
||||||
|
pageSize: pageSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
lib/editor/render/ink_picture_cache.dart
Normal file
87
lib/editor/render/ink_picture_cache.dart
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
// lib/editor/render/ink_picture_cache.dart
|
||||||
|
//
|
||||||
|
// Bounded LRU cache of ui.Picture objects keyed by "hostId:revision".
|
||||||
|
//
|
||||||
|
// Resolution-independent: ink is vector, so a single Picture is valid at any
|
||||||
|
// zoom level. There are NO DPI buckets.
|
||||||
|
//
|
||||||
|
// Evicted Pictures are disposed via a post-frame callback so Flutter's raster
|
||||||
|
// thread is never asked to delete a Picture it may still be reading.
|
||||||
|
|
||||||
|
import 'dart:collection';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// Bounded LRU cache of [ui.Picture]s keyed by a string (typically
|
||||||
|
/// `"$hostId:$revision"`).
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// ```dart
|
||||||
|
/// final picture = cache.getOrBuild(hostId, store.revision, size, () {
|
||||||
|
/// final recorder = ui.PictureRecorder();
|
||||||
|
/// final canvas = ui.Canvas(recorder);
|
||||||
|
/// // … draw …
|
||||||
|
/// return recorder.endRecording();
|
||||||
|
/// });
|
||||||
|
/// canvas.drawPicture(picture);
|
||||||
|
/// ```
|
||||||
|
class InkPictureCache {
|
||||||
|
InkPictureCache({int maxSize = 12}) : _maxSize = maxSize;
|
||||||
|
|
||||||
|
final int _maxSize;
|
||||||
|
|
||||||
|
// LinkedHashMap preserves insertion order; we move accessed entries to the
|
||||||
|
// back so the front is always the least-recently used.
|
||||||
|
final LinkedHashMap<String, ui.Picture> _cache =
|
||||||
|
LinkedHashMap<String, ui.Picture>();
|
||||||
|
|
||||||
|
/// Returns a cached [ui.Picture] for [key], or calls [build] to create one.
|
||||||
|
///
|
||||||
|
/// The [key] should encode all inputs that affect the picture content (host
|
||||||
|
/// id + revision, at minimum). [size] and [build] are only used on a cache
|
||||||
|
/// miss.
|
||||||
|
ui.Picture getOrBuild(
|
||||||
|
String hostId,
|
||||||
|
int revision,
|
||||||
|
ui.Size size,
|
||||||
|
ui.Picture Function() build,
|
||||||
|
) {
|
||||||
|
final key = '$hostId:$revision';
|
||||||
|
|
||||||
|
if (_cache.containsKey(key)) {
|
||||||
|
// Promote to most-recently-used by reinserting at the back.
|
||||||
|
final pic = _cache.remove(key)!;
|
||||||
|
_cache[key] = pic;
|
||||||
|
return pic;
|
||||||
|
}
|
||||||
|
|
||||||
|
final picture = build();
|
||||||
|
_cache[key] = picture;
|
||||||
|
|
||||||
|
// Evict least-recently-used entries beyond the cap.
|
||||||
|
while (_cache.length > _maxSize) {
|
||||||
|
final lruKey = _cache.keys.first;
|
||||||
|
final evicted = _cache.remove(lruKey)!;
|
||||||
|
_disposeDeferred(evicted);
|
||||||
|
}
|
||||||
|
|
||||||
|
return picture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disposes all cached Pictures, deferring the actual disposal to a
|
||||||
|
/// post-frame callback so any in-flight raster work can complete.
|
||||||
|
void dispose() {
|
||||||
|
final pictures = List<ui.Picture>.from(_cache.values);
|
||||||
|
_cache.clear();
|
||||||
|
for (final pic in pictures) {
|
||||||
|
_disposeDeferred(pic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _disposeDeferred(ui.Picture picture) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
picture.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
47
lib/editor/render/live_ink_painter.dart
Normal file
47
lib/editor/render/live_ink_painter.dart
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// lib/editor/render/live_ink_painter.dart
|
||||||
|
//
|
||||||
|
// CustomPainter for the in-progress stroke (live) layer.
|
||||||
|
//
|
||||||
|
// Paints only the single EditorStroke? currently being drawn, with
|
||||||
|
// isComplete:false so perfect_freehand tapers the trailing end correctly.
|
||||||
|
// Kept in a separate RepaintBoundary so committed strokes are never
|
||||||
|
// re-rasterized on pointer-move events.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../engine/stroke_geometry.dart';
|
||||||
|
import '../engine/stroke_model.dart';
|
||||||
|
|
||||||
|
/// Paints the single in-progress [EditorStroke] (or nothing when [live] is
|
||||||
|
/// null / empty). Use alongside [StaticInkPainter] in stacked [CustomPaint]s.
|
||||||
|
class LiveInkPainter extends CustomPainter {
|
||||||
|
const LiveInkPainter({
|
||||||
|
required this.live,
|
||||||
|
required this.pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The stroke currently being drawn, or null when idle.
|
||||||
|
final EditorStroke? live;
|
||||||
|
final Size pageSize;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final stroke = live;
|
||||||
|
if (stroke == null || stroke.points.isEmpty) return;
|
||||||
|
|
||||||
|
final path = buildStrokeOutline(stroke, pageSize, isComplete: false);
|
||||||
|
if (path.getBounds().isEmpty) return;
|
||||||
|
|
||||||
|
canvas.drawPath(
|
||||||
|
path,
|
||||||
|
Paint()
|
||||||
|
..color = Color(stroke.color)
|
||||||
|
..style = PaintingStyle.fill
|
||||||
|
..isAntiAlias = true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(LiveInkPainter old) =>
|
||||||
|
!identical(old.live, live) || old.pageSize != pageSize;
|
||||||
|
}
|
||||||
69
lib/editor/render/static_ink_painter.dart
Normal file
69
lib/editor/render/static_ink_painter.dart
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
// lib/editor/render/static_ink_painter.dart
|
||||||
|
//
|
||||||
|
// CustomPainter for the committed-stroke (static) layer.
|
||||||
|
//
|
||||||
|
// paint() gets-or-builds a ui.Picture of all committed strokes keyed by
|
||||||
|
// store.revision, then delegates to canvas.drawPicture — so as long as the
|
||||||
|
// revision is unchanged the raster thread replays the same display list at
|
||||||
|
// zero CPU cost.
|
||||||
|
//
|
||||||
|
// shouldRepaint() is O(1): it compares the revision int and pageSize only.
|
||||||
|
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../engine/stroke_geometry.dart';
|
||||||
|
import '../engine/stroke_store.dart';
|
||||||
|
import 'ink_picture_cache.dart';
|
||||||
|
|
||||||
|
/// Paints the committed ink layer by recording strokes into a [ui.Picture]
|
||||||
|
/// once per [StrokeStore.revision] and caching it in [InkPictureCache].
|
||||||
|
///
|
||||||
|
/// Place this inside a [RepaintBoundary] / [CustomPaint] pair. The sibling
|
||||||
|
/// [LiveInkPainter] handles the in-progress stroke in a separate layer.
|
||||||
|
class StaticInkPainter extends CustomPainter {
|
||||||
|
StaticInkPainter({
|
||||||
|
required this.hostId,
|
||||||
|
required this.store,
|
||||||
|
required this.pageSize,
|
||||||
|
required this.cache,
|
||||||
|
}) : revision = store.revision;
|
||||||
|
|
||||||
|
final String hostId;
|
||||||
|
final StrokeStore store;
|
||||||
|
final Size pageSize;
|
||||||
|
final InkPictureCache cache;
|
||||||
|
|
||||||
|
/// Revision snapshot captured at construction time. Used by [shouldRepaint]
|
||||||
|
/// so two painters built at different revisions compare correctly even when
|
||||||
|
/// they share the same [StrokeStore] instance.
|
||||||
|
final int revision;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final picture = cache.getOrBuild(hostId, store.revision, pageSize, () {
|
||||||
|
final recorder = ui.PictureRecorder();
|
||||||
|
final rec = Canvas(recorder);
|
||||||
|
for (final stroke in store.committed) {
|
||||||
|
final path =
|
||||||
|
buildStrokeOutline(stroke, pageSize, isComplete: true);
|
||||||
|
if (path.getBounds().isEmpty) continue;
|
||||||
|
rec.drawPath(
|
||||||
|
path,
|
||||||
|
Paint()
|
||||||
|
..color = Color(stroke.color)
|
||||||
|
..style = PaintingStyle.fill
|
||||||
|
..isAntiAlias = true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return recorder.endRecording();
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.drawPicture(picture);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(StaticInkPainter old) =>
|
||||||
|
old.revision != store.revision || old.pageSize != pageSize;
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ class DatabaseService {
|
|||||||
|
|
||||||
_database = await openDatabase(
|
_database = await openDatabase(
|
||||||
dbPath,
|
dbPath,
|
||||||
version: 5,
|
version: 6,
|
||||||
onCreate: _onCreate,
|
onCreate: _onCreate,
|
||||||
onUpgrade: _onUpgrade,
|
onUpgrade: _onUpgrade,
|
||||||
);
|
);
|
||||||
@@ -156,12 +156,71 @@ class DatabaseService {
|
|||||||
await db.execute(
|
await db.execute(
|
||||||
'CREATE INDEX idx_scratchpads_doc ON scratchpads(document_id)',
|
'CREATE INDEX idx_scratchpads_doc ON scratchpads(document_id)',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Editor ink strokes (v6)
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ink (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
host_kind TEXT NOT NULL,
|
||||||
|
host_id TEXT NOT NULL,
|
||||||
|
stroke_json TEXT NOT NULL,
|
||||||
|
ordinal INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX idx_ink_host ON ink(host_kind, host_id)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Notebook pages (v6)
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE notebook_pages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
document_id TEXT NOT NULL,
|
||||||
|
ordinal INTEGER NOT NULL,
|
||||||
|
source_page_index INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
|
||||||
if (oldVersion < 3) await _migrateV2toV3(db);
|
if (oldVersion < 3) await _migrateV2toV3(db);
|
||||||
if (oldVersion < 4) {} // v3->v4: version boundary (no-op schema)
|
if (oldVersion < 4) {} // v3->v4: version boundary (no-op schema)
|
||||||
if (oldVersion < 5) await _migrateV4toV5(db);
|
if (oldVersion < 5) await _migrateV4toV5(db);
|
||||||
|
if (oldVersion < 6) await _migrateV5toV6(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _migrateV5toV6(Database db) async {
|
||||||
|
await db.transaction((txn) async {
|
||||||
|
await txn.execute('''
|
||||||
|
CREATE TABLE ink (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
host_kind TEXT NOT NULL,
|
||||||
|
host_id TEXT NOT NULL,
|
||||||
|
stroke_json TEXT NOT NULL,
|
||||||
|
ordinal INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
await txn.execute(
|
||||||
|
'CREATE INDEX idx_ink_host ON ink(host_kind, host_id)',
|
||||||
|
);
|
||||||
|
|
||||||
|
await txn.execute('''
|
||||||
|
CREATE TABLE notebook_pages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
document_id TEXT NOT NULL,
|
||||||
|
ordinal INTEGER NOT NULL,
|
||||||
|
source_page_index INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _migrateV2toV3(Database db) async {
|
Future<void> _migrateV2toV3(Database db) async {
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import 'dart:ui';
|
|||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
||||||
import 'package:syncfusion_flutter_pdf/pdf.dart';
|
import 'package:syncfusion_flutter_pdf/pdf.dart';
|
||||||
|
|
||||||
import '../models/ink_stroke.dart';
|
import '../models/ink_stroke.dart';
|
||||||
|
import '../models/pen_tool.dart';
|
||||||
|
|
||||||
/// Service for PDF operations: file picking, info extraction, and annotation export.
|
/// Service for PDF operations: file picking, info extraction, and annotation export.
|
||||||
class PdfService {
|
class PdfService {
|
||||||
@@ -201,6 +203,11 @@ class PdfService {
|
|||||||
|
|
||||||
/// Renders [strokes] onto a PDF [page] using normalized [0, 1] coordinates
|
/// Renders [strokes] onto a PDF [page] using normalized [0, 1] coordinates
|
||||||
/// scaled to the actual page dimensions.
|
/// scaled to the actual page dimensions.
|
||||||
|
///
|
||||||
|
/// Freehand tools (pen, marker, highlighter) are rendered as filled outline
|
||||||
|
/// polygons produced by perfect_freehand's [getStroke], matching the
|
||||||
|
/// on-screen filled-nib appearance. Shape tools and text keep their existing
|
||||||
|
/// pen-stroke semantics.
|
||||||
void _renderStrokes(PdfPage page, List<InkStroke> strokes) {
|
void _renderStrokes(PdfPage page, List<InkStroke> strokes) {
|
||||||
final graphics = page.graphics;
|
final graphics = page.graphics;
|
||||||
final pageSize = page.getClientSize();
|
final pageSize = page.getClientSize();
|
||||||
@@ -213,33 +220,107 @@ class PdfService {
|
|||||||
final g = (color >> 8) & 0xFF;
|
final g = (color >> 8) & 0xFF;
|
||||||
final b = color & 0xFF;
|
final b = color & 0xFF;
|
||||||
final a = (color >> 24) & 0xFF;
|
final a = (color >> 24) & 0xFF;
|
||||||
|
final pdfColor = PdfColor(r, g, b, a);
|
||||||
|
|
||||||
final pen = PdfPen(PdfColor(r, g, b, a));
|
final isFreehand = stroke.tool == PenTool.pen ||
|
||||||
pen.width = stroke.strokeWidth.clamp(1.0, 8.0);
|
stroke.tool == PenTool.marker ||
|
||||||
|
stroke.tool == PenTool.highlighter;
|
||||||
|
|
||||||
if (stroke.points.length == 1) {
|
if (isFreehand) {
|
||||||
// Single point — draw a dot
|
final brush = PdfSolidBrush(pdfColor);
|
||||||
final pt = stroke.points.first;
|
|
||||||
graphics.drawEllipse(
|
if (stroke.points.length == 1) {
|
||||||
Rect.fromCenter(
|
// Single point — filled dot matching stroke width.
|
||||||
center: Offset(pt.x * pageSize.width, pt.y * pageSize.height),
|
final pt = stroke.points.first;
|
||||||
width: stroke.strokeWidth,
|
final radius = stroke.strokeWidth / 2;
|
||||||
height: stroke.strokeWidth,
|
graphics.drawEllipse(
|
||||||
),
|
Rect.fromCenter(
|
||||||
pen: pen,
|
center: Offset(pt.x * pageSize.width, pt.y * pageSize.height),
|
||||||
);
|
width: radius * 2,
|
||||||
} else {
|
height: radius * 2,
|
||||||
// Draw line segments between consecutive points
|
),
|
||||||
for (int i = 0; i < stroke.points.length - 1; i++) {
|
brush: brush,
|
||||||
final p1 = stroke.points[i];
|
|
||||||
final p2 = stroke.points[i + 1];
|
|
||||||
graphics.drawLine(
|
|
||||||
pen,
|
|
||||||
Offset(p1.x * pageSize.width, p1.y * pageSize.height),
|
|
||||||
Offset(p2.x * pageSize.width, p2.y * pageSize.height),
|
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
final pdfPath = _buildFreehandPdfPath(stroke, pageSize);
|
||||||
|
if (pdfPath != null) {
|
||||||
|
graphics.drawPath(pdfPath, brush: brush);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Shape tools and text: keep existing pen-segment semantics.
|
||||||
|
final pen = PdfPen(pdfColor);
|
||||||
|
pen.width = stroke.strokeWidth.clamp(1.0, 8.0);
|
||||||
|
|
||||||
|
if (stroke.points.length == 1) {
|
||||||
|
final pt = stroke.points.first;
|
||||||
|
graphics.drawEllipse(
|
||||||
|
Rect.fromCenter(
|
||||||
|
center: Offset(pt.x * pageSize.width, pt.y * pageSize.height),
|
||||||
|
width: stroke.strokeWidth,
|
||||||
|
height: stroke.strokeWidth,
|
||||||
|
),
|
||||||
|
pen: pen,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
for (int i = 0; i < stroke.points.length - 1; i++) {
|
||||||
|
final p1 = stroke.points[i];
|
||||||
|
final p2 = stroke.points[i + 1];
|
||||||
|
graphics.drawLine(
|
||||||
|
pen,
|
||||||
|
Offset(p1.x * pageSize.width, p1.y * pageSize.height),
|
||||||
|
Offset(p2.x * pageSize.width, p2.y * pageSize.height),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds a [PdfPath] filled outline polygon for a freehand [stroke] using
|
||||||
|
/// perfect_freehand's [getStroke], matching the on-screen recipe from
|
||||||
|
/// [lib/editor/engine/stroke_geometry.dart].
|
||||||
|
///
|
||||||
|
/// Points are scaled from normalized [0,1] coords into PDF-point space
|
||||||
|
/// defined by [pageSize] before being passed to [getStroke], so the
|
||||||
|
/// resulting outline is already in PDF coordinates.
|
||||||
|
///
|
||||||
|
/// Returns null when [getStroke] produces an empty outline.
|
||||||
|
PdfPath? _buildFreehandPdfPath(InkStroke stroke, Size pageSize) {
|
||||||
|
final isHighlighter = stroke.tool == PenTool.highlighter;
|
||||||
|
final pixelWidth = stroke.strokeWidth * pageSize.width;
|
||||||
|
|
||||||
|
// Detect real stylus pressure: the InkPoint default is 0.5, so any point
|
||||||
|
// that differs from the default indicates actual device pressure data.
|
||||||
|
final hasRealPressure = stroke.points.any((pt) => pt.pressure != 0.5);
|
||||||
|
|
||||||
|
final pfPoints = stroke.points
|
||||||
|
.map(
|
||||||
|
(pt) => pf.Point(
|
||||||
|
pt.x * pageSize.width,
|
||||||
|
pt.y * pageSize.height,
|
||||||
|
pt.pressure,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final outline = pf.getStroke(
|
||||||
|
pfPoints,
|
||||||
|
size: pixelWidth,
|
||||||
|
// Highlighter keeps constant width; pen/marker taper via thinning=0.7.
|
||||||
|
thinning: isHighlighter ? 0.0 : 0.7,
|
||||||
|
smoothing: 0.5,
|
||||||
|
streamline: 0.5,
|
||||||
|
// Real stylus pressure -> don't simulate; no pressure -> let freehand
|
||||||
|
// fake it based on velocity. Highlighter never simulates.
|
||||||
|
simulatePressure: !hasRealPressure && !isHighlighter,
|
||||||
|
isComplete: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (outline.isEmpty) return null;
|
||||||
|
|
||||||
|
final path = PdfPath();
|
||||||
|
path.addPolygon(outline.map((pt) => Offset(pt.x, pt.y)).toList());
|
||||||
|
return path;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
160
test/editor_render_test.dart
Normal file
160
test/editor_render_test.dart
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
// test/editor_render_test.dart
|
||||||
|
//
|
||||||
|
// Unit tests for the P0 RENDER layer:
|
||||||
|
// (a) StrokeStore: add/removeById/replaceAll/clear all bump revision.
|
||||||
|
// (b) StaticInkPainter.shouldRepaint: FALSE when revision+pageSize unchanged,
|
||||||
|
// TRUE when revision changes. (The key perf invariant.)
|
||||||
|
//
|
||||||
|
// Tests are intentionally pure-logic: no widget pump, no GPU, no image
|
||||||
|
// comparisons. StaticInkPainter.shouldRepaint only reads store.revision and
|
||||||
|
// pageSize so we can exercise it without a real ui.Picture or Canvas.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:badnote/editor/engine/stroke_model.dart';
|
||||||
|
import 'package:badnote/editor/engine/stroke_store.dart';
|
||||||
|
import 'package:badnote/editor/render/ink_picture_cache.dart';
|
||||||
|
import 'package:badnote/editor/render/static_ink_painter.dart';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
EditorStroke _stroke(String id) => EditorStroke.create(
|
||||||
|
id: id,
|
||||||
|
points: const [
|
||||||
|
EditorPoint(x: 0.1, y: 0.1, pressure: 0.5),
|
||||||
|
EditorPoint(x: 0.5, y: 0.5, pressure: 0.5),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Builds a [StaticInkPainter] that can be interrogated via [shouldRepaint]
|
||||||
|
/// without ever calling [paint] (avoids needing a real Canvas / Picture).
|
||||||
|
StaticInkPainter _painter(StrokeStore store, Size pageSize) =>
|
||||||
|
StaticInkPainter(
|
||||||
|
hostId: 'test-host',
|
||||||
|
store: store,
|
||||||
|
pageSize: pageSize,
|
||||||
|
cache: InkPictureCache(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const pageSize = Size(800.0, 600.0);
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
group('StrokeStore revision', () {
|
||||||
|
test('starts at 0', () {
|
||||||
|
final store = StrokeStore();
|
||||||
|
expect(store.revision, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add bumps revision', () {
|
||||||
|
final store = StrokeStore();
|
||||||
|
store.add(_stroke('s1'));
|
||||||
|
expect(store.revision, 1);
|
||||||
|
store.add(_stroke('s2'));
|
||||||
|
expect(store.revision, 2);
|
||||||
|
expect(store.committed.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeById bumps revision when stroke is found', () {
|
||||||
|
final store = StrokeStore()..add(_stroke('s1'));
|
||||||
|
final revBefore = store.revision;
|
||||||
|
store.removeById('s1');
|
||||||
|
expect(store.revision, greaterThan(revBefore));
|
||||||
|
expect(store.committed, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeById does NOT bump revision when id is absent', () {
|
||||||
|
final store = StrokeStore()..add(_stroke('s1'));
|
||||||
|
final revBefore = store.revision;
|
||||||
|
store.removeById('nonexistent');
|
||||||
|
expect(store.revision, revBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replaceAll bumps revision', () {
|
||||||
|
final store = StrokeStore();
|
||||||
|
store.replaceAll([_stroke('a'), _stroke('b')]);
|
||||||
|
expect(store.revision, 1);
|
||||||
|
expect(store.committed.length, 2);
|
||||||
|
|
||||||
|
store.replaceAll([_stroke('c')]);
|
||||||
|
expect(store.revision, 2);
|
||||||
|
expect(store.committed.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clear bumps revision', () {
|
||||||
|
final store = StrokeStore()..add(_stroke('x'));
|
||||||
|
final revBefore = store.revision;
|
||||||
|
store.clear();
|
||||||
|
expect(store.revision, greaterThan(revBefore));
|
||||||
|
expect(store.committed, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('committed returns unmodifiable list', () {
|
||||||
|
final store = StrokeStore()..add(_stroke('s'));
|
||||||
|
expect(() => store.committed.add(_stroke('bad')), throwsUnsupportedError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
group('StaticInkPainter.shouldRepaint', () {
|
||||||
|
test('returns false when revision and pageSize are unchanged', () {
|
||||||
|
final store = StrokeStore()..add(_stroke('s1'));
|
||||||
|
final p1 = _painter(store, pageSize);
|
||||||
|
final p2 = _painter(store, pageSize);
|
||||||
|
|
||||||
|
// Both painters wrap the same store at the same revision.
|
||||||
|
expect(p2.shouldRepaint(p1), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns true when revision changes', () {
|
||||||
|
final store = StrokeStore()..add(_stroke('s1'));
|
||||||
|
final p1 = _painter(store, pageSize);
|
||||||
|
|
||||||
|
// Mutate the store — revision bumps.
|
||||||
|
store.add(_stroke('s2'));
|
||||||
|
final p2 = _painter(store, pageSize);
|
||||||
|
|
||||||
|
expect(p2.shouldRepaint(p1), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns true when only pageSize changes', () {
|
||||||
|
final store = StrokeStore()..add(_stroke('s1'));
|
||||||
|
final p1 = _painter(store, pageSize);
|
||||||
|
final p2 = _painter(store, const Size(1024.0, 768.0));
|
||||||
|
|
||||||
|
expect(p2.shouldRepaint(p1), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false after replaceAll with same content (revision differs '
|
||||||
|
'but separate store instances — uses store.revision not identity)', () {
|
||||||
|
// This test confirms shouldRepaint uses the revision INTEGER, not object
|
||||||
|
// identity, so a brand-new store at revision 0 == another at revision 0.
|
||||||
|
final storeA = StrokeStore(); // revision 0
|
||||||
|
final storeB = StrokeStore(); // revision 0
|
||||||
|
final pA = _painter(storeA, pageSize);
|
||||||
|
final pB = _painter(storeB, pageSize);
|
||||||
|
expect(pB.shouldRepaint(pA), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns true when old painter had higher revision than new '
|
||||||
|
'(regression: revision comparison is not directional guard)', () {
|
||||||
|
final store = StrokeStore()
|
||||||
|
..add(_stroke('a'))
|
||||||
|
..add(_stroke('b')); // revision == 2
|
||||||
|
final pOld = _painter(store, pageSize);
|
||||||
|
|
||||||
|
store.clear(); // revision == 3
|
||||||
|
final pNew = _painter(store, pageSize);
|
||||||
|
|
||||||
|
// pNew.revision (3) != pOld.revision (2) → should repaint.
|
||||||
|
expect(pNew.shouldRepaint(pOld), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
246
test/editor_repository_test.dart
Normal file
246
test/editor_repository_test.dart
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
// test/editor_repository_test.dart
|
||||||
|
//
|
||||||
|
// Tests for EditorRepository (MF3 diff-write contract + round-trip).
|
||||||
|
//
|
||||||
|
// Run via:
|
||||||
|
// bash tool/test.sh test/editor_repository_test.dart
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:sqflite_common/sqlite_api.dart';
|
||||||
|
import 'package:sqflite_common/utils/utils.dart' as sqflite_utils;
|
||||||
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
|
|
||||||
|
import 'package:badnote/editor/engine/stroke_model.dart';
|
||||||
|
import 'package:badnote/editor/persistence/editor_repository.dart';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Open an in-memory sqflite database with the ink + notebook_pages schema.
|
||||||
|
Future<Database> _openTestDb() async {
|
||||||
|
sqfliteFfiInit();
|
||||||
|
final factory = databaseFactoryFfi;
|
||||||
|
|
||||||
|
// Use a temp-file DB so the test is isolated but still exercises real I/O.
|
||||||
|
final dir = await Directory.systemTemp.createTemp('editor_repo_test_');
|
||||||
|
final path = p.join(dir.path, 'test.db');
|
||||||
|
|
||||||
|
return factory.openDatabase(
|
||||||
|
path,
|
||||||
|
options: OpenDatabaseOptions(
|
||||||
|
version: 1,
|
||||||
|
onCreate: (db, version) async {
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE ink (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
host_kind TEXT NOT NULL,
|
||||||
|
host_id TEXT NOT NULL,
|
||||||
|
stroke_json TEXT NOT NULL,
|
||||||
|
ordinal INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX idx_ink_host ON ink(host_kind, host_id)',
|
||||||
|
);
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE notebook_pages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
document_id TEXT NOT NULL,
|
||||||
|
ordinal INTEGER NOT NULL,
|
||||||
|
source_page_index INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a minimal [EditorStroke] with a given [id].
|
||||||
|
EditorStroke _stroke(String id) => EditorStroke.create(
|
||||||
|
id: id,
|
||||||
|
points: [
|
||||||
|
const EditorPoint(x: 0.1, y: 0.2),
|
||||||
|
const EditorPoint(x: 0.3, y: 0.4),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Build [n] distinct strokes.
|
||||||
|
List<EditorStroke> _strokes(int n) =>
|
||||||
|
List.generate(n, (i) => _stroke('stroke-$i'));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(() {
|
||||||
|
sqfliteFfiInit();
|
||||||
|
databaseFactory = databaseFactoryFfi;
|
||||||
|
});
|
||||||
|
|
||||||
|
group('EditorRepository', () {
|
||||||
|
late Database db;
|
||||||
|
late EditorRepository repo;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
db = await _openTestDb();
|
||||||
|
repo = EditorRepository(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Round-trip ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('round-trip: saveHost then loadDocument returns same strokes', () async {
|
||||||
|
const docId = 'doc-rt';
|
||||||
|
final hostId = EditorRepository.pageHostId(docId, 0);
|
||||||
|
final original = _strokes(5);
|
||||||
|
|
||||||
|
await repo.saveHost('page', hostId, original);
|
||||||
|
|
||||||
|
final loaded = await repo.loadDocument(docId);
|
||||||
|
|
||||||
|
expect(loaded.containsKey(hostId), isTrue);
|
||||||
|
final returned = loaded[hostId]!;
|
||||||
|
expect(returned.length, equals(original.length));
|
||||||
|
for (var i = 0; i < original.length; i++) {
|
||||||
|
expect(returned[i].id, equals(original[i].id));
|
||||||
|
expect(returned[i].points.length, equals(original[i].points.length));
|
||||||
|
expect(returned[i].color, equals(original[i].color));
|
||||||
|
expect(returned[i].width, equals(original[i].width));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 2000-stroke seed + 1-stroke delete ────────────────────────────
|
||||||
|
|
||||||
|
test(
|
||||||
|
'seed 2000 strokes, delete 1: second save issues exactly 1 DELETE and 0 INSERTs',
|
||||||
|
() async {
|
||||||
|
const docId = 'doc-2000';
|
||||||
|
final hostId = EditorRepository.pageHostId(docId, 0);
|
||||||
|
final all = _strokes(2000);
|
||||||
|
|
||||||
|
// First save: all 2000 strokes inserted (not under test here).
|
||||||
|
await repo.saveHost('page', hostId, all);
|
||||||
|
|
||||||
|
// Verify row count is 2000.
|
||||||
|
final countBefore = sqflite_utils.firstIntValue(
|
||||||
|
await db.rawQuery(
|
||||||
|
'SELECT COUNT(*) FROM ink WHERE host_id = ?',
|
||||||
|
[hostId],
|
||||||
|
),
|
||||||
|
)!;
|
||||||
|
expect(countBefore, equals(2000));
|
||||||
|
|
||||||
|
// Record which ids existed before the deletion.
|
||||||
|
final idsBefore = (await db.query(
|
||||||
|
'ink',
|
||||||
|
columns: ['id'],
|
||||||
|
where: 'host_id = ?',
|
||||||
|
whereArgs: [hostId],
|
||||||
|
))
|
||||||
|
.map((r) => r['id'] as String)
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
// Remove stroke at index 500 (arbitrary) — simulate 1 erasure.
|
||||||
|
final strokeToRemove = all[500];
|
||||||
|
final reduced = List<EditorStroke>.from(all)..removeAt(500);
|
||||||
|
|
||||||
|
// Second save: diff should produce exactly 1 DELETE, 0 INSERTs.
|
||||||
|
await repo.saveHost('page', hostId, reduced);
|
||||||
|
|
||||||
|
final countAfter = sqflite_utils.firstIntValue(
|
||||||
|
await db.rawQuery(
|
||||||
|
'SELECT COUNT(*) FROM ink WHERE host_id = ?',
|
||||||
|
[hostId],
|
||||||
|
),
|
||||||
|
)!;
|
||||||
|
|
||||||
|
// Row count must drop by exactly 1.
|
||||||
|
expect(countAfter, equals(1999));
|
||||||
|
|
||||||
|
// The removed stroke must no longer exist.
|
||||||
|
final removedRows = await db.query(
|
||||||
|
'ink',
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [strokeToRemove.id],
|
||||||
|
);
|
||||||
|
expect(removedRows, isEmpty);
|
||||||
|
|
||||||
|
// All 1999 surviving ids must be unchanged.
|
||||||
|
final idsAfter = (await db.query(
|
||||||
|
'ink',
|
||||||
|
columns: ['id'],
|
||||||
|
where: 'host_id = ?',
|
||||||
|
whereArgs: [hostId],
|
||||||
|
))
|
||||||
|
.map((r) => r['id'] as String)
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
final expectedSurvivors = Set<String>.from(idsBefore)
|
||||||
|
..remove(strokeToRemove.id);
|
||||||
|
expect(idsAfter, equals(expectedSurvivors));
|
||||||
|
|
||||||
|
// No new ids were created (zero INSERTs for the second save).
|
||||||
|
final newIds = idsAfter.difference(idsBefore);
|
||||||
|
expect(newIds, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Multiple hosts in same document ───────────────────────────────
|
||||||
|
|
||||||
|
test('loadDocument returns strokes for multiple pages', () async {
|
||||||
|
const docId = 'doc-multi';
|
||||||
|
final host0 = EditorRepository.pageHostId(docId, 0);
|
||||||
|
final host1 = EditorRepository.pageHostId(docId, 1);
|
||||||
|
|
||||||
|
final strokes0 = _strokes(3);
|
||||||
|
final strokes1 = _strokes(4).map((s) => _stroke('pg1-${s.id}')).toList();
|
||||||
|
|
||||||
|
await repo.saveHost('page', host0, strokes0);
|
||||||
|
await repo.saveHost('page', host1, strokes1);
|
||||||
|
|
||||||
|
final loaded = await repo.loadDocument(docId);
|
||||||
|
expect(loaded[host0]!.length, equals(3));
|
||||||
|
expect(loaded[host1]!.length, equals(4));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Idempotency ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('saving the same strokes twice is a no-op (0 DB mutations)', () async {
|
||||||
|
const docId = 'doc-idem';
|
||||||
|
final hostId = EditorRepository.pageHostId(docId, 0);
|
||||||
|
final strokes = _strokes(10);
|
||||||
|
|
||||||
|
await repo.saveHost('page', hostId, strokes);
|
||||||
|
|
||||||
|
final countBefore = sqflite_utils.firstIntValue(
|
||||||
|
await db.rawQuery(
|
||||||
|
'SELECT COUNT(*) FROM ink WHERE host_id = ?',
|
||||||
|
[hostId],
|
||||||
|
),
|
||||||
|
)!;
|
||||||
|
|
||||||
|
// Second save with identical strokes: should be a no-op.
|
||||||
|
await repo.saveHost('page', hostId, strokes);
|
||||||
|
|
||||||
|
final countAfter = sqflite_utils.firstIntValue(
|
||||||
|
await db.rawQuery(
|
||||||
|
'SELECT COUNT(*) FROM ink WHERE host_id = ?',
|
||||||
|
[hostId],
|
||||||
|
),
|
||||||
|
)!;
|
||||||
|
|
||||||
|
expect(countAfter, equals(countBefore));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
180
test/editor_stroke_model_test.dart
Normal file
180
test/editor_stroke_model_test.dart
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
// test/editor_stroke_model_test.dart
|
||||||
|
//
|
||||||
|
// Pure unit tests for the engine foundation (NO DB, NO widgets):
|
||||||
|
// (a) EditorStroke -> json -> EditorStroke round-trips losslessly,
|
||||||
|
// including tilt/timestamp/pointerDeviceKind.
|
||||||
|
// (b) EditorStroke <-> InkStroke round-trips losslessly.
|
||||||
|
// (c) buildStrokeOutline returns a non-empty Path for a >=2-point stroke and
|
||||||
|
// an empty Path for 0 points.
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:badnote/editor/engine/stroke_geometry.dart';
|
||||||
|
import 'package:badnote/editor/engine/stroke_model.dart';
|
||||||
|
import 'package:badnote/models/ink_point.dart';
|
||||||
|
import 'package:badnote/models/ink_stroke.dart';
|
||||||
|
import 'package:badnote/models/pen_tool.dart';
|
||||||
|
import 'package:badnote/models/pointer_device_kind.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('EditorStroke JSON round-trip', () {
|
||||||
|
test('round-trips losslessly incl. tilt/timestamp/pointerDeviceKind', () {
|
||||||
|
final stroke = EditorStroke(
|
||||||
|
id: 'stroke-1',
|
||||||
|
points: const [
|
||||||
|
EditorPoint(
|
||||||
|
x: 0.1,
|
||||||
|
y: 0.2,
|
||||||
|
pressure: 0.75,
|
||||||
|
tilt: 0.33,
|
||||||
|
timestamp: 1234567,
|
||||||
|
pointerDeviceKind: InputDeviceKind.stylus,
|
||||||
|
),
|
||||||
|
EditorPoint(
|
||||||
|
x: 0.4,
|
||||||
|
y: 0.5,
|
||||||
|
pressure: 0.5,
|
||||||
|
tilt: 0.0,
|
||||||
|
timestamp: 1234600,
|
||||||
|
pointerDeviceKind: InputDeviceKind.invertedStylus,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
tool: EditorTool.highlighter,
|
||||||
|
color: 0xFFAABBCC,
|
||||||
|
width: 0.0123,
|
||||||
|
filled: true,
|
||||||
|
textContent: 'hello',
|
||||||
|
fontSize: 18.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persisted as a JSON string (DB TEXT column); jsonEncode invokes nested
|
||||||
|
// toJson, jsonDecode rebuilds the maps.
|
||||||
|
final decoded = EditorStroke.fromJson(
|
||||||
|
jsonDecode(jsonEncode(stroke.toJson())) as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(decoded, stroke);
|
||||||
|
// Spot-check the superset fields explicitly.
|
||||||
|
expect(decoded.points.first.tilt, 0.33);
|
||||||
|
expect(decoded.points.first.timestamp, 1234567);
|
||||||
|
expect(
|
||||||
|
decoded.points.first.pointerDeviceKind,
|
||||||
|
InputDeviceKind.stylus,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
decoded.points.last.pointerDeviceKind,
|
||||||
|
InputDeviceKind.invertedStylus,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves null superset fields', () {
|
||||||
|
final stroke = EditorStroke(
|
||||||
|
id: 'stroke-null',
|
||||||
|
points: const [
|
||||||
|
EditorPoint(x: 0.0, y: 0.0),
|
||||||
|
EditorPoint(x: 1.0, y: 1.0, pressure: 0.9),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final decoded = EditorStroke.fromJson(
|
||||||
|
jsonDecode(jsonEncode(stroke.toJson())) as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(decoded, stroke);
|
||||||
|
expect(decoded.points.first.pressure, isNull);
|
||||||
|
expect(decoded.points.first.tilt, isNull);
|
||||||
|
expect(decoded.points.first.timestamp, isNull);
|
||||||
|
expect(decoded.points.first.pointerDeviceKind, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('EditorStroke <-> InkStroke round-trip', () {
|
||||||
|
test('InkStroke -> EditorStroke -> InkStroke is lossless', () {
|
||||||
|
final ink = InkStroke(
|
||||||
|
id: 'ink-1',
|
||||||
|
points: const [
|
||||||
|
InkPoint(
|
||||||
|
x: 0.2,
|
||||||
|
y: 0.3,
|
||||||
|
pressure: 0.8,
|
||||||
|
tilt: 0.1,
|
||||||
|
timestamp: 999,
|
||||||
|
pointerDeviceKind: InputDeviceKind.stylus,
|
||||||
|
),
|
||||||
|
InkPoint(
|
||||||
|
x: 0.6,
|
||||||
|
y: 0.7,
|
||||||
|
pressure: 0.4,
|
||||||
|
tilt: 0.2,
|
||||||
|
timestamp: 1050,
|
||||||
|
pointerDeviceKind: InputDeviceKind.touch,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
tool: PenTool.highlighter,
|
||||||
|
color: 0xFF112233,
|
||||||
|
strokeWidth: 0.02,
|
||||||
|
createdAt: DateTime.fromMillisecondsSinceEpoch(42),
|
||||||
|
filled: true,
|
||||||
|
textContent: 'note',
|
||||||
|
fontSize: 22.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
final editor = EditorStroke.fromInkStroke(ink);
|
||||||
|
final back = editor.toInkStroke(createdAt: ink.createdAt);
|
||||||
|
|
||||||
|
expect(back, ink);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EditorStroke (non-null fields) -> InkStroke -> EditorStroke', () {
|
||||||
|
final editor = EditorStroke(
|
||||||
|
id: 'ink-2',
|
||||||
|
points: const [
|
||||||
|
EditorPoint(
|
||||||
|
x: 0.1,
|
||||||
|
y: 0.1,
|
||||||
|
pressure: 0.5,
|
||||||
|
tilt: 0.0,
|
||||||
|
timestamp: 7,
|
||||||
|
pointerDeviceKind: InputDeviceKind.mouse,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
tool: EditorTool.eraser,
|
||||||
|
color: 0xFF000000,
|
||||||
|
width: 0.004,
|
||||||
|
);
|
||||||
|
|
||||||
|
final ink = editor.toInkStroke();
|
||||||
|
final back = EditorStroke.fromInkStroke(ink);
|
||||||
|
|
||||||
|
expect(back, editor);
|
||||||
|
expect(ink.tool, PenTool.eraser);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('buildStrokeOutline', () {
|
||||||
|
const pageSize = Size(800, 600);
|
||||||
|
|
||||||
|
test('returns a non-empty Path for a >=2-point stroke', () {
|
||||||
|
final stroke = EditorStroke(
|
||||||
|
id: 's',
|
||||||
|
points: const [
|
||||||
|
EditorPoint(x: 0.1, y: 0.1, pressure: 0.6),
|
||||||
|
EditorPoint(x: 0.5, y: 0.4, pressure: 0.6),
|
||||||
|
EditorPoint(x: 0.8, y: 0.9, pressure: 0.6),
|
||||||
|
],
|
||||||
|
width: 0.01,
|
||||||
|
);
|
||||||
|
|
||||||
|
final path = buildStrokeOutline(stroke, pageSize, isComplete: true);
|
||||||
|
expect(path.getBounds().isEmpty, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns an empty Path for 0 points', () {
|
||||||
|
final stroke = EditorStroke(id: 'empty', points: const []);
|
||||||
|
final path = buildStrokeOutline(stroke, pageSize, isComplete: true);
|
||||||
|
expect(path.getBounds().isEmpty, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user