feat(engine): P0 stroke engine + persistence

Per the full-refactor plan §9 (input-independent half of P0):
- engine: canonical EditorStroke (lossless InkStroke round-trip) +
  stroke_geometry (single getStroke outline) + revision-gated StrokeStore
- render: static/live ink painters + ink_picture_cache (revision-keyed)
  + annotation_layer (RepaintBoundary)
- persistence: DB v6 (ink, notebook_pages) + editor_repository diff-write
  (UPSERT changed / DELETE removed in one txn; id-set after commit) +
  save_scheduler
- pdf_service export now FILLS the getStroke outline (R7 hairline fix)
Not yet wired into the live editor (input relocation pending pen-pressure
diagnostic). 28 new tests pass.
This commit is contained in:
2026-06-21 23:41:01 +08:00
parent 1e2a83b0b9
commit 914951afb7
16 changed files with 2267 additions and 23 deletions

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -41,7 +41,7 @@ class DatabaseService {
_database = await openDatabase(
dbPath,
version: 5,
version: 6,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
@@ -156,12 +156,71 @@ class DatabaseService {
await db.execute(
'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 {
if (oldVersion < 3) await _migrateV2toV3(db);
if (oldVersion < 4) {} // v3->v4: version boundary (no-op schema)
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 {

View File

@@ -5,9 +5,11 @@ import 'dart:ui';
import 'package:file_picker/file_picker.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
import 'package:syncfusion_flutter_pdf/pdf.dart';
import '../models/ink_stroke.dart';
import '../models/pen_tool.dart';
/// Service for PDF operations: file picking, info extraction, and annotation export.
class PdfService {
@@ -201,6 +203,11 @@ class PdfService {
/// Renders [strokes] onto a PDF [page] using normalized [0, 1] coordinates
/// 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) {
final graphics = page.graphics;
final pageSize = page.getClientSize();
@@ -213,33 +220,107 @@ class PdfService {
final g = (color >> 8) & 0xFF;
final b = color & 0xFF;
final a = (color >> 24) & 0xFF;
final pdfColor = PdfColor(r, g, b, a);
final pen = PdfPen(PdfColor(r, g, b, a));
pen.width = stroke.strokeWidth.clamp(1.0, 8.0);
final isFreehand = stroke.tool == PenTool.pen ||
stroke.tool == PenTool.marker ||
stroke.tool == PenTool.highlighter;
if (stroke.points.length == 1) {
// Single point — draw a dot
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 {
// Draw line segments between consecutive points
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),
if (isFreehand) {
final brush = PdfSolidBrush(pdfColor);
if (stroke.points.length == 1) {
// Single point — filled dot matching stroke width.
final pt = stroke.points.first;
final radius = stroke.strokeWidth / 2;
graphics.drawEllipse(
Rect.fromCenter(
center: Offset(pt.x * pageSize.width, pt.y * pageSize.height),
width: radius * 2,
height: radius * 2,
),
brush: brush,
);
} 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;
}
}

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

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

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