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

Bug fixes (Flutter):
- Wrap multi-statement DB writes (insert/update/delete note, deleteDocument,
  deletePageData, OCR FTS merge, migrations) in transactions to prevent data
  loss on interruption and a read-modify-write FTS race.
- Fix PdfDocument leaks on exception (try/finally dispose) and preserve image
  aspect ratio when stamping images onto PDF pages.
- Guard file-picker against empty selection (was .single -> crash).
- Fix eraser ConcurrentModificationError and unmodifiable-list crash on PDF
  pages; capture page synchronously on save to stop wrong-page data loss.
- Fix Riverpod DB-not-ready races, broken pull-to-refresh, settings load race,
  and search N+1; transform stored annotations on PDF page rotation.
- Normalize pen pressure for devices without a pressure range.
- PPT: single source of truth for slide strokes so ink displays and exports.

UI/UX:
- Material 3 typography, theme-aware colors (dark-mode fixes), hover cursors
  and right-click/visible actions on desktop, keyboard shortcuts (undo/redo/
  save/find), toolbar overflow handling, friendlier empty states, semantic OCR
  status badges, relative timestamps, 1-based page indicators, large-deck PPT
  navigation, and a scratchpad-scope label in split view.

Server (optional backend):
- Persist JWT secret (was per-process random), block path traversal in storage,
  fix CORS '*'+credentials, add OCR job ownership checks, last-writer-wins sync
  guard, constant-time login, and split out heavy OCR deps so the API/tests run
  without them.

CI: Gitea workflows for format+analyze+test (Linux, system sqlite) and a
Windows release build; pristine `flutter analyze`, all Flutter and server tests
green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 03:18:00 +08:00
commit 72428dc075
210 changed files with 18171 additions and 0 deletions

55
lib/main.dart Normal file
View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'providers/settings_provider.dart';
import 'screens/home_screen.dart';
import 'services/database_service.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Ensure DB is ready before the app starts so providers can use it eagerly
await DatabaseService.getInstance();
// Initialize SharedPreferences
await SharedPreferences.getInstance();
runApp(const ProviderScope(child: BadNoteApp()));
}
class BadNoteApp extends ConsumerWidget {
const BadNoteApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
return MaterialApp(
title: 'BadNote',
themeMode: settings.themeMode,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: settings.colorSchemeSeed,
brightness: Brightness.light,
),
textTheme: GoogleFonts.interTextTheme(
ThemeData(brightness: Brightness.light).textTheme,
),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: settings.colorSchemeSeed,
brightness: Brightness.dark,
),
textTheme: GoogleFonts.interTextTheme(
ThemeData(brightness: Brightness.dark).textTheme,
),
useMaterial3: true,
),
home: const HomeScreen(),
);
}
}

19
lib/models/bookmark.dart Normal file
View File

@@ -0,0 +1,19 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'bookmark.freezed.dart';
part 'bookmark.g.dart';
@freezed
abstract class Bookmark with _$Bookmark {
const factory Bookmark({
required String id,
required String documentId,
required int pageNumber,
@Default('') String label,
@Default(0xFF2196F3) int color,
required DateTime createdAt,
}) = _Bookmark;
factory Bookmark.fromJson(Map<String, dynamic> json) =>
_$BookmarkFromJson(json);
}

View File

@@ -0,0 +1,290 @@
// 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 'bookmark.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',
);
Bookmark _$BookmarkFromJson(Map<String, dynamic> json) {
return _Bookmark.fromJson(json);
}
/// @nodoc
mixin _$Bookmark {
String get id => throw _privateConstructorUsedError;
String get documentId => throw _privateConstructorUsedError;
int get pageNumber => throw _privateConstructorUsedError;
String get label => throw _privateConstructorUsedError;
int get color => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
/// Serializes this Bookmark to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of Bookmark
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$BookmarkCopyWith<Bookmark> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $BookmarkCopyWith<$Res> {
factory $BookmarkCopyWith(Bookmark value, $Res Function(Bookmark) then) =
_$BookmarkCopyWithImpl<$Res, Bookmark>;
@useResult
$Res call({
String id,
String documentId,
int pageNumber,
String label,
int color,
DateTime createdAt,
});
}
/// @nodoc
class _$BookmarkCopyWithImpl<$Res, $Val extends Bookmark>
implements $BookmarkCopyWith<$Res> {
_$BookmarkCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of Bookmark
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? documentId = null,
Object? pageNumber = null,
Object? label = null,
Object? color = null,
Object? createdAt = null,
}) {
return _then(
_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
documentId: null == documentId
? _value.documentId
: documentId // ignore: cast_nullable_to_non_nullable
as String,
pageNumber: null == pageNumber
? _value.pageNumber
: pageNumber // ignore: cast_nullable_to_non_nullable
as int,
label: null == label
? _value.label
: label // ignore: cast_nullable_to_non_nullable
as String,
color: null == color
? _value.color
: color // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$BookmarkImplCopyWith<$Res>
implements $BookmarkCopyWith<$Res> {
factory _$$BookmarkImplCopyWith(
_$BookmarkImpl value,
$Res Function(_$BookmarkImpl) then,
) = __$$BookmarkImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
String id,
String documentId,
int pageNumber,
String label,
int color,
DateTime createdAt,
});
}
/// @nodoc
class __$$BookmarkImplCopyWithImpl<$Res>
extends _$BookmarkCopyWithImpl<$Res, _$BookmarkImpl>
implements _$$BookmarkImplCopyWith<$Res> {
__$$BookmarkImplCopyWithImpl(
_$BookmarkImpl _value,
$Res Function(_$BookmarkImpl) _then,
) : super(_value, _then);
/// Create a copy of Bookmark
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? documentId = null,
Object? pageNumber = null,
Object? label = null,
Object? color = null,
Object? createdAt = null,
}) {
return _then(
_$BookmarkImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
documentId: null == documentId
? _value.documentId
: documentId // ignore: cast_nullable_to_non_nullable
as String,
pageNumber: null == pageNumber
? _value.pageNumber
: pageNumber // ignore: cast_nullable_to_non_nullable
as int,
label: null == label
? _value.label
: label // ignore: cast_nullable_to_non_nullable
as String,
color: null == color
? _value.color
: color // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
),
);
}
}
/// @nodoc
@JsonSerializable()
class _$BookmarkImpl implements _Bookmark {
const _$BookmarkImpl({
required this.id,
required this.documentId,
required this.pageNumber,
this.label = '',
this.color = 0xFF2196F3,
required this.createdAt,
});
factory _$BookmarkImpl.fromJson(Map<String, dynamic> json) =>
_$$BookmarkImplFromJson(json);
@override
final String id;
@override
final String documentId;
@override
final int pageNumber;
@override
@JsonKey()
final String label;
@override
@JsonKey()
final int color;
@override
final DateTime createdAt;
@override
String toString() {
return 'Bookmark(id: $id, documentId: $documentId, pageNumber: $pageNumber, label: $label, color: $color, createdAt: $createdAt)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$BookmarkImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.documentId, documentId) ||
other.documentId == documentId) &&
(identical(other.pageNumber, pageNumber) ||
other.pageNumber == pageNumber) &&
(identical(other.label, label) || other.label == label) &&
(identical(other.color, color) || other.color == color) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
documentId,
pageNumber,
label,
color,
createdAt,
);
/// Create a copy of Bookmark
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$BookmarkImplCopyWith<_$BookmarkImpl> get copyWith =>
__$$BookmarkImplCopyWithImpl<_$BookmarkImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$BookmarkImplToJson(this);
}
}
abstract class _Bookmark implements Bookmark {
const factory _Bookmark({
required final String id,
required final String documentId,
required final int pageNumber,
final String label,
final int color,
required final DateTime createdAt,
}) = _$BookmarkImpl;
factory _Bookmark.fromJson(Map<String, dynamic> json) =
_$BookmarkImpl.fromJson;
@override
String get id;
@override
String get documentId;
@override
int get pageNumber;
@override
String get label;
@override
int get color;
@override
DateTime get createdAt;
/// Create a copy of Bookmark
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$BookmarkImplCopyWith<_$BookmarkImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'bookmark.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$BookmarkImpl _$$BookmarkImplFromJson(Map<String, dynamic> json) =>
_$BookmarkImpl(
id: json['id'] as String,
documentId: json['documentId'] as String,
pageNumber: (json['pageNumber'] as num).toInt(),
label: json['label'] as String? ?? '',
color: (json['color'] as num?)?.toInt() ?? 0xFF2196F3,
createdAt: DateTime.parse(json['createdAt'] as String),
);
Map<String, dynamic> _$$BookmarkImplToJson(_$BookmarkImpl instance) =>
<String, dynamic>{
'id': instance.id,
'documentId': instance.documentId,
'pageNumber': instance.pageNumber,
'label': instance.label,
'color': instance.color,
'createdAt': instance.createdAt.toIso8601String(),
};

21
lib/models/document.dart Normal file
View File

@@ -0,0 +1,21 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'document.freezed.dart';
part 'document.g.dart';
@freezed
abstract class Document with _$Document {
const factory Document({
required String id,
required String filename,
required String docType,
required String filePath,
@Default(0) int pageCount,
@Default(0) int rotation,
required DateTime createdAt,
required DateTime updatedAt,
}) = _Document;
factory Document.fromJson(Map<String, dynamic> json) =>
_$DocumentFromJson(json);
}

View File

@@ -0,0 +1,335 @@
// 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 'document.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',
);
Document _$DocumentFromJson(Map<String, dynamic> json) {
return _Document.fromJson(json);
}
/// @nodoc
mixin _$Document {
String get id => throw _privateConstructorUsedError;
String get filename => throw _privateConstructorUsedError;
String get docType => throw _privateConstructorUsedError;
String get filePath => throw _privateConstructorUsedError;
int get pageCount => throw _privateConstructorUsedError;
int get rotation => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
DateTime get updatedAt => throw _privateConstructorUsedError;
/// Serializes this Document to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of Document
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$DocumentCopyWith<Document> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $DocumentCopyWith<$Res> {
factory $DocumentCopyWith(Document value, $Res Function(Document) then) =
_$DocumentCopyWithImpl<$Res, Document>;
@useResult
$Res call({
String id,
String filename,
String docType,
String filePath,
int pageCount,
int rotation,
DateTime createdAt,
DateTime updatedAt,
});
}
/// @nodoc
class _$DocumentCopyWithImpl<$Res, $Val extends Document>
implements $DocumentCopyWith<$Res> {
_$DocumentCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of Document
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? filename = null,
Object? docType = null,
Object? filePath = null,
Object? pageCount = null,
Object? rotation = null,
Object? createdAt = null,
Object? updatedAt = null,
}) {
return _then(
_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
filename: null == filename
? _value.filename
: filename // ignore: cast_nullable_to_non_nullable
as String,
docType: null == docType
? _value.docType
: docType // ignore: cast_nullable_to_non_nullable
as String,
filePath: null == filePath
? _value.filePath
: filePath // ignore: cast_nullable_to_non_nullable
as String,
pageCount: null == pageCount
? _value.pageCount
: pageCount // ignore: cast_nullable_to_non_nullable
as int,
rotation: null == rotation
? _value.rotation
: rotation // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$DocumentImplCopyWith<$Res>
implements $DocumentCopyWith<$Res> {
factory _$$DocumentImplCopyWith(
_$DocumentImpl value,
$Res Function(_$DocumentImpl) then,
) = __$$DocumentImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
String id,
String filename,
String docType,
String filePath,
int pageCount,
int rotation,
DateTime createdAt,
DateTime updatedAt,
});
}
/// @nodoc
class __$$DocumentImplCopyWithImpl<$Res>
extends _$DocumentCopyWithImpl<$Res, _$DocumentImpl>
implements _$$DocumentImplCopyWith<$Res> {
__$$DocumentImplCopyWithImpl(
_$DocumentImpl _value,
$Res Function(_$DocumentImpl) _then,
) : super(_value, _then);
/// Create a copy of Document
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? filename = null,
Object? docType = null,
Object? filePath = null,
Object? pageCount = null,
Object? rotation = null,
Object? createdAt = null,
Object? updatedAt = null,
}) {
return _then(
_$DocumentImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
filename: null == filename
? _value.filename
: filename // ignore: cast_nullable_to_non_nullable
as String,
docType: null == docType
? _value.docType
: docType // ignore: cast_nullable_to_non_nullable
as String,
filePath: null == filePath
? _value.filePath
: filePath // ignore: cast_nullable_to_non_nullable
as String,
pageCount: null == pageCount
? _value.pageCount
: pageCount // ignore: cast_nullable_to_non_nullable
as int,
rotation: null == rotation
? _value.rotation
: rotation // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
),
);
}
}
/// @nodoc
@JsonSerializable()
class _$DocumentImpl implements _Document {
const _$DocumentImpl({
required this.id,
required this.filename,
required this.docType,
required this.filePath,
this.pageCount = 0,
this.rotation = 0,
required this.createdAt,
required this.updatedAt,
});
factory _$DocumentImpl.fromJson(Map<String, dynamic> json) =>
_$$DocumentImplFromJson(json);
@override
final String id;
@override
final String filename;
@override
final String docType;
@override
final String filePath;
@override
@JsonKey()
final int pageCount;
@override
@JsonKey()
final int rotation;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
String toString() {
return 'Document(id: $id, filename: $filename, docType: $docType, filePath: $filePath, pageCount: $pageCount, rotation: $rotation, createdAt: $createdAt, updatedAt: $updatedAt)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$DocumentImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.filename, filename) ||
other.filename == filename) &&
(identical(other.docType, docType) || other.docType == docType) &&
(identical(other.filePath, filePath) ||
other.filePath == filePath) &&
(identical(other.pageCount, pageCount) ||
other.pageCount == pageCount) &&
(identical(other.rotation, rotation) ||
other.rotation == rotation) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
filename,
docType,
filePath,
pageCount,
rotation,
createdAt,
updatedAt,
);
/// Create a copy of Document
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$DocumentImplCopyWith<_$DocumentImpl> get copyWith =>
__$$DocumentImplCopyWithImpl<_$DocumentImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$DocumentImplToJson(this);
}
}
abstract class _Document implements Document {
const factory _Document({
required final String id,
required final String filename,
required final String docType,
required final String filePath,
final int pageCount,
final int rotation,
required final DateTime createdAt,
required final DateTime updatedAt,
}) = _$DocumentImpl;
factory _Document.fromJson(Map<String, dynamic> json) =
_$DocumentImpl.fromJson;
@override
String get id;
@override
String get filename;
@override
String get docType;
@override
String get filePath;
@override
int get pageCount;
@override
int get rotation;
@override
DateTime get createdAt;
@override
DateTime get updatedAt;
/// Create a copy of Document
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$DocumentImplCopyWith<_$DocumentImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,31 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'document.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$DocumentImpl _$$DocumentImplFromJson(Map<String, dynamic> json) =>
_$DocumentImpl(
id: json['id'] as String,
filename: json['filename'] as String,
docType: json['docType'] as String,
filePath: json['filePath'] as String,
pageCount: (json['pageCount'] as num?)?.toInt() ?? 0,
rotation: (json['rotation'] as num?)?.toInt() ?? 0,
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
);
Map<String, dynamic> _$$DocumentImplToJson(_$DocumentImpl instance) =>
<String, dynamic>{
'id': instance.id,
'filename': instance.filename,
'docType': instance.docType,
'filePath': instance.filePath,
'pageCount': instance.pageCount,
'rotation': instance.rotation,
'createdAt': instance.createdAt.toIso8601String(),
'updatedAt': instance.updatedAt.toIso8601String(),
};

21
lib/models/ink_point.dart Normal file
View File

@@ -0,0 +1,21 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'pointer_device_kind.dart';
part 'ink_point.freezed.dart';
part 'ink_point.g.dart';
@freezed
abstract class InkPoint with _$InkPoint {
const factory InkPoint({
required double x,
required double y,
@Default(0.5) double pressure,
@Default(0.0) double tilt,
required int timestamp,
@Default(InputDeviceKind.unknown) InputDeviceKind pointerDeviceKind,
}) = _InkPoint;
factory InkPoint.fromJson(Map<String, dynamic> json) =>
_$InkPointFromJson(json);
}

View File

@@ -0,0 +1,291 @@
// 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 'ink_point.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',
);
InkPoint _$InkPointFromJson(Map<String, dynamic> json) {
return _InkPoint.fromJson(json);
}
/// @nodoc
mixin _$InkPoint {
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 InkPoint to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of InkPoint
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$InkPointCopyWith<InkPoint> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $InkPointCopyWith<$Res> {
factory $InkPointCopyWith(InkPoint value, $Res Function(InkPoint) then) =
_$InkPointCopyWithImpl<$Res, InkPoint>;
@useResult
$Res call({
double x,
double y,
double pressure,
double tilt,
int timestamp,
InputDeviceKind pointerDeviceKind,
});
}
/// @nodoc
class _$InkPointCopyWithImpl<$Res, $Val extends InkPoint>
implements $InkPointCopyWith<$Res> {
_$InkPointCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of InkPoint
/// 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 = null,
Object? tilt = null,
Object? timestamp = null,
Object? pointerDeviceKind = null,
}) {
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: null == pressure
? _value.pressure
: pressure // ignore: cast_nullable_to_non_nullable
as double,
tilt: null == tilt
? _value.tilt
: tilt // ignore: cast_nullable_to_non_nullable
as double,
timestamp: null == timestamp
? _value.timestamp
: timestamp // ignore: cast_nullable_to_non_nullable
as int,
pointerDeviceKind: null == pointerDeviceKind
? _value.pointerDeviceKind
: pointerDeviceKind // ignore: cast_nullable_to_non_nullable
as InputDeviceKind,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$InkPointImplCopyWith<$Res>
implements $InkPointCopyWith<$Res> {
factory _$$InkPointImplCopyWith(
_$InkPointImpl value,
$Res Function(_$InkPointImpl) then,
) = __$$InkPointImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
double x,
double y,
double pressure,
double tilt,
int timestamp,
InputDeviceKind pointerDeviceKind,
});
}
/// @nodoc
class __$$InkPointImplCopyWithImpl<$Res>
extends _$InkPointCopyWithImpl<$Res, _$InkPointImpl>
implements _$$InkPointImplCopyWith<$Res> {
__$$InkPointImplCopyWithImpl(
_$InkPointImpl _value,
$Res Function(_$InkPointImpl) _then,
) : super(_value, _then);
/// Create a copy of InkPoint
/// 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 = null,
Object? tilt = null,
Object? timestamp = null,
Object? pointerDeviceKind = null,
}) {
return _then(
_$InkPointImpl(
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: null == pressure
? _value.pressure
: pressure // ignore: cast_nullable_to_non_nullable
as double,
tilt: null == tilt
? _value.tilt
: tilt // ignore: cast_nullable_to_non_nullable
as double,
timestamp: null == timestamp
? _value.timestamp
: timestamp // ignore: cast_nullable_to_non_nullable
as int,
pointerDeviceKind: null == pointerDeviceKind
? _value.pointerDeviceKind
: pointerDeviceKind // ignore: cast_nullable_to_non_nullable
as InputDeviceKind,
),
);
}
}
/// @nodoc
@JsonSerializable()
class _$InkPointImpl implements _InkPoint {
const _$InkPointImpl({
required this.x,
required this.y,
this.pressure = 0.5,
this.tilt = 0.0,
required this.timestamp,
this.pointerDeviceKind = InputDeviceKind.unknown,
});
factory _$InkPointImpl.fromJson(Map<String, dynamic> json) =>
_$$InkPointImplFromJson(json);
@override
final double x;
@override
final double y;
@override
@JsonKey()
final double pressure;
@override
@JsonKey()
final double tilt;
@override
final int timestamp;
@override
@JsonKey()
final InputDeviceKind pointerDeviceKind;
@override
String toString() {
return 'InkPoint(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 _$InkPointImpl &&
(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 InkPoint
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$InkPointImplCopyWith<_$InkPointImpl> get copyWith =>
__$$InkPointImplCopyWithImpl<_$InkPointImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$InkPointImplToJson(this);
}
}
abstract class _InkPoint implements InkPoint {
const factory _InkPoint({
required final double x,
required final double y,
final double pressure,
final double tilt,
required final int timestamp,
final InputDeviceKind pointerDeviceKind,
}) = _$InkPointImpl;
factory _InkPoint.fromJson(Map<String, dynamic> json) =
_$InkPointImpl.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 InkPoint
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$InkPointImplCopyWith<_$InkPointImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,42 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'ink_point.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$InkPointImpl _$$InkPointImplFromJson(Map<String, dynamic> json) =>
_$InkPointImpl(
x: (json['x'] as num).toDouble(),
y: (json['y'] as num).toDouble(),
pressure: (json['pressure'] as num?)?.toDouble() ?? 0.5,
tilt: (json['tilt'] as num?)?.toDouble() ?? 0.0,
timestamp: (json['timestamp'] as num).toInt(),
pointerDeviceKind:
$enumDecodeNullable(
_$InputDeviceKindEnumMap,
json['pointerDeviceKind'],
) ??
InputDeviceKind.unknown,
);
Map<String, dynamic> _$$InkPointImplToJson(
_$InkPointImpl 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',
};

View File

@@ -0,0 +1,25 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'ink_point.dart';
import 'pen_tool.dart';
part 'ink_stroke.freezed.dart';
part 'ink_stroke.g.dart';
@freezed
abstract class InkStroke with _$InkStroke {
const factory InkStroke({
required String id,
required List<InkPoint> points,
@Default(PenTool.pen) PenTool tool,
@Default(0xFF000000) int color,
@Default(2.0) double strokeWidth,
required DateTime createdAt,
@Default(false) bool filled,
String? textContent,
@Default(14.0) double fontSize,
}) = _InkStroke;
factory InkStroke.fromJson(Map<String, dynamic> json) =>
_$InkStrokeFromJson(json);
}

View File

@@ -0,0 +1,363 @@
// 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 'ink_stroke.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',
);
InkStroke _$InkStrokeFromJson(Map<String, dynamic> json) {
return _InkStroke.fromJson(json);
}
/// @nodoc
mixin _$InkStroke {
String get id => throw _privateConstructorUsedError;
List<InkPoint> get points => throw _privateConstructorUsedError;
PenTool get tool => throw _privateConstructorUsedError;
int get color => throw _privateConstructorUsedError;
double get strokeWidth => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
bool get filled => throw _privateConstructorUsedError;
String? get textContent => throw _privateConstructorUsedError;
double get fontSize => throw _privateConstructorUsedError;
/// Serializes this InkStroke to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of InkStroke
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$InkStrokeCopyWith<InkStroke> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $InkStrokeCopyWith<$Res> {
factory $InkStrokeCopyWith(InkStroke value, $Res Function(InkStroke) then) =
_$InkStrokeCopyWithImpl<$Res, InkStroke>;
@useResult
$Res call({
String id,
List<InkPoint> points,
PenTool tool,
int color,
double strokeWidth,
DateTime createdAt,
bool filled,
String? textContent,
double fontSize,
});
}
/// @nodoc
class _$InkStrokeCopyWithImpl<$Res, $Val extends InkStroke>
implements $InkStrokeCopyWith<$Res> {
_$InkStrokeCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of InkStroke
/// 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? strokeWidth = null,
Object? createdAt = 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<InkPoint>,
tool: null == tool
? _value.tool
: tool // ignore: cast_nullable_to_non_nullable
as PenTool,
color: null == color
? _value.color
: color // ignore: cast_nullable_to_non_nullable
as int,
strokeWidth: null == strokeWidth
? _value.strokeWidth
: strokeWidth // ignore: cast_nullable_to_non_nullable
as double,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
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 _$$InkStrokeImplCopyWith<$Res>
implements $InkStrokeCopyWith<$Res> {
factory _$$InkStrokeImplCopyWith(
_$InkStrokeImpl value,
$Res Function(_$InkStrokeImpl) then,
) = __$$InkStrokeImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
String id,
List<InkPoint> points,
PenTool tool,
int color,
double strokeWidth,
DateTime createdAt,
bool filled,
String? textContent,
double fontSize,
});
}
/// @nodoc
class __$$InkStrokeImplCopyWithImpl<$Res>
extends _$InkStrokeCopyWithImpl<$Res, _$InkStrokeImpl>
implements _$$InkStrokeImplCopyWith<$Res> {
__$$InkStrokeImplCopyWithImpl(
_$InkStrokeImpl _value,
$Res Function(_$InkStrokeImpl) _then,
) : super(_value, _then);
/// Create a copy of InkStroke
/// 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? strokeWidth = null,
Object? createdAt = null,
Object? filled = null,
Object? textContent = freezed,
Object? fontSize = null,
}) {
return _then(
_$InkStrokeImpl(
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<InkPoint>,
tool: null == tool
? _value.tool
: tool // ignore: cast_nullable_to_non_nullable
as PenTool,
color: null == color
? _value.color
: color // ignore: cast_nullable_to_non_nullable
as int,
strokeWidth: null == strokeWidth
? _value.strokeWidth
: strokeWidth // ignore: cast_nullable_to_non_nullable
as double,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
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 _$InkStrokeImpl implements _InkStroke {
const _$InkStrokeImpl({
required this.id,
required final List<InkPoint> points,
this.tool = PenTool.pen,
this.color = 0xFF000000,
this.strokeWidth = 2.0,
required this.createdAt,
this.filled = false,
this.textContent,
this.fontSize = 14.0,
}) : _points = points;
factory _$InkStrokeImpl.fromJson(Map<String, dynamic> json) =>
_$$InkStrokeImplFromJson(json);
@override
final String id;
final List<InkPoint> _points;
@override
List<InkPoint> get points {
if (_points is EqualUnmodifiableListView) return _points;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_points);
}
@override
@JsonKey()
final PenTool tool;
@override
@JsonKey()
final int color;
@override
@JsonKey()
final double strokeWidth;
@override
final DateTime createdAt;
@override
@JsonKey()
final bool filled;
@override
final String? textContent;
@override
@JsonKey()
final double fontSize;
@override
String toString() {
return 'InkStroke(id: $id, points: $points, tool: $tool, color: $color, strokeWidth: $strokeWidth, createdAt: $createdAt, filled: $filled, textContent: $textContent, fontSize: $fontSize)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$InkStrokeImpl &&
(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.strokeWidth, strokeWidth) ||
other.strokeWidth == strokeWidth) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(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,
strokeWidth,
createdAt,
filled,
textContent,
fontSize,
);
/// Create a copy of InkStroke
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$InkStrokeImplCopyWith<_$InkStrokeImpl> get copyWith =>
__$$InkStrokeImplCopyWithImpl<_$InkStrokeImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$InkStrokeImplToJson(this);
}
}
abstract class _InkStroke implements InkStroke {
const factory _InkStroke({
required final String id,
required final List<InkPoint> points,
final PenTool tool,
final int color,
final double strokeWidth,
required final DateTime createdAt,
final bool filled,
final String? textContent,
final double fontSize,
}) = _$InkStrokeImpl;
factory _InkStroke.fromJson(Map<String, dynamic> json) =
_$InkStrokeImpl.fromJson;
@override
String get id;
@override
List<InkPoint> get points;
@override
PenTool get tool;
@override
int get color;
@override
double get strokeWidth;
@override
DateTime get createdAt;
@override
bool get filled;
@override
String? get textContent;
@override
double get fontSize;
/// Create a copy of InkStroke
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$InkStrokeImplCopyWith<_$InkStrokeImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,47 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'ink_stroke.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$InkStrokeImpl _$$InkStrokeImplFromJson(Map<String, dynamic> json) =>
_$InkStrokeImpl(
id: json['id'] as String,
points: (json['points'] as List<dynamic>)
.map((e) => InkPoint.fromJson(e as Map<String, dynamic>))
.toList(),
tool: $enumDecodeNullable(_$PenToolEnumMap, json['tool']) ?? PenTool.pen,
color: (json['color'] as num?)?.toInt() ?? 0xFF000000,
strokeWidth: (json['strokeWidth'] as num?)?.toDouble() ?? 2.0,
createdAt: DateTime.parse(json['createdAt'] as String),
filled: json['filled'] as bool? ?? false,
textContent: json['textContent'] as String?,
fontSize: (json['fontSize'] as num?)?.toDouble() ?? 14.0,
);
Map<String, dynamic> _$$InkStrokeImplToJson(_$InkStrokeImpl instance) =>
<String, dynamic>{
'id': instance.id,
'points': instance.points,
'tool': _$PenToolEnumMap[instance.tool]!,
'color': instance.color,
'strokeWidth': instance.strokeWidth,
'createdAt': instance.createdAt.toIso8601String(),
'filled': instance.filled,
'textContent': instance.textContent,
'fontSize': instance.fontSize,
};
const _$PenToolEnumMap = {
PenTool.pen: 'pen',
PenTool.marker: 'marker',
PenTool.eraser: 'eraser',
PenTool.highlighter: 'highlighter',
PenTool.rectangle: 'rectangle',
PenTool.ellipse: 'ellipse',
PenTool.line: 'line',
PenTool.arrow: 'arrow',
PenTool.text: 'text',
};

20
lib/models/note.dart Normal file
View File

@@ -0,0 +1,20 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'ink_stroke.dart';
part 'note.freezed.dart';
part 'note.g.dart';
@freezed
abstract class Note with _$Note {
const factory Note({
required String id,
@Default('Untitled') String title,
@Default([]) List<InkStroke> strokes,
required DateTime createdAt,
required DateTime updatedAt,
@Default([]) List<String> tags,
}) = _Note;
factory Note.fromJson(Map<String, dynamic> json) => _$NoteFromJson(json);
}

View File

@@ -0,0 +1,297 @@
// 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 'note.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',
);
Note _$NoteFromJson(Map<String, dynamic> json) {
return _Note.fromJson(json);
}
/// @nodoc
mixin _$Note {
String get id => throw _privateConstructorUsedError;
String get title => throw _privateConstructorUsedError;
List<InkStroke> get strokes => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
DateTime get updatedAt => throw _privateConstructorUsedError;
List<String> get tags => throw _privateConstructorUsedError;
/// Serializes this Note to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of Note
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$NoteCopyWith<Note> get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $NoteCopyWith<$Res> {
factory $NoteCopyWith(Note value, $Res Function(Note) then) =
_$NoteCopyWithImpl<$Res, Note>;
@useResult
$Res call({
String id,
String title,
List<InkStroke> strokes,
DateTime createdAt,
DateTime updatedAt,
List<String> tags,
});
}
/// @nodoc
class _$NoteCopyWithImpl<$Res, $Val extends Note>
implements $NoteCopyWith<$Res> {
_$NoteCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of Note
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? title = null,
Object? strokes = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? tags = null,
}) {
return _then(
_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
title: null == title
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String,
strokes: null == strokes
? _value.strokes
: strokes // ignore: cast_nullable_to_non_nullable
as List<InkStroke>,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
tags: null == tags
? _value.tags
: tags // ignore: cast_nullable_to_non_nullable
as List<String>,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$NoteImplCopyWith<$Res> implements $NoteCopyWith<$Res> {
factory _$$NoteImplCopyWith(
_$NoteImpl value,
$Res Function(_$NoteImpl) then,
) = __$$NoteImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
String id,
String title,
List<InkStroke> strokes,
DateTime createdAt,
DateTime updatedAt,
List<String> tags,
});
}
/// @nodoc
class __$$NoteImplCopyWithImpl<$Res>
extends _$NoteCopyWithImpl<$Res, _$NoteImpl>
implements _$$NoteImplCopyWith<$Res> {
__$$NoteImplCopyWithImpl(_$NoteImpl _value, $Res Function(_$NoteImpl) _then)
: super(_value, _then);
/// Create a copy of Note
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? title = null,
Object? strokes = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? tags = null,
}) {
return _then(
_$NoteImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
title: null == title
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String,
strokes: null == strokes
? _value._strokes
: strokes // ignore: cast_nullable_to_non_nullable
as List<InkStroke>,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
tags: null == tags
? _value._tags
: tags // ignore: cast_nullable_to_non_nullable
as List<String>,
),
);
}
}
/// @nodoc
@JsonSerializable()
class _$NoteImpl implements _Note {
const _$NoteImpl({
required this.id,
this.title = 'Untitled',
final List<InkStroke> strokes = const [],
required this.createdAt,
required this.updatedAt,
final List<String> tags = const [],
}) : _strokes = strokes,
_tags = tags;
factory _$NoteImpl.fromJson(Map<String, dynamic> json) =>
_$$NoteImplFromJson(json);
@override
final String id;
@override
@JsonKey()
final String title;
final List<InkStroke> _strokes;
@override
@JsonKey()
List<InkStroke> get strokes {
if (_strokes is EqualUnmodifiableListView) return _strokes;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_strokes);
}
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
final List<String> _tags;
@override
@JsonKey()
List<String> get tags {
if (_tags is EqualUnmodifiableListView) return _tags;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_tags);
}
@override
String toString() {
return 'Note(id: $id, title: $title, strokes: $strokes, createdAt: $createdAt, updatedAt: $updatedAt, tags: $tags)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$NoteImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.title, title) || other.title == title) &&
const DeepCollectionEquality().equals(other._strokes, _strokes) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
const DeepCollectionEquality().equals(other._tags, _tags));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
title,
const DeepCollectionEquality().hash(_strokes),
createdAt,
updatedAt,
const DeepCollectionEquality().hash(_tags),
);
/// Create a copy of Note
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$NoteImplCopyWith<_$NoteImpl> get copyWith =>
__$$NoteImplCopyWithImpl<_$NoteImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$NoteImplToJson(this);
}
}
abstract class _Note implements Note {
const factory _Note({
required final String id,
final String title,
final List<InkStroke> strokes,
required final DateTime createdAt,
required final DateTime updatedAt,
final List<String> tags,
}) = _$NoteImpl;
factory _Note.fromJson(Map<String, dynamic> json) = _$NoteImpl.fromJson;
@override
String get id;
@override
String get title;
@override
List<InkStroke> get strokes;
@override
DateTime get createdAt;
@override
DateTime get updatedAt;
@override
List<String> get tags;
/// Create a copy of Note
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$NoteImplCopyWith<_$NoteImpl> get copyWith =>
throw _privateConstructorUsedError;
}

32
lib/models/note.g.dart Normal file
View File

@@ -0,0 +1,32 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'note.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$NoteImpl _$$NoteImplFromJson(Map<String, dynamic> json) => _$NoteImpl(
id: json['id'] as String,
title: json['title'] as String? ?? 'Untitled',
strokes:
(json['strokes'] as List<dynamic>?)
?.map((e) => InkStroke.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
tags:
(json['tags'] as List<dynamic>?)?.map((e) => e as String).toList() ??
const [],
);
Map<String, dynamic> _$$NoteImplToJson(_$NoteImpl instance) =>
<String, dynamic>{
'id': instance.id,
'title': instance.title,
'strokes': instance.strokes,
'createdAt': instance.createdAt.toIso8601String(),
'updatedAt': instance.updatedAt.toIso8601String(),
'tags': instance.tags,
};

11
lib/models/pen_tool.dart Normal file
View File

@@ -0,0 +1,11 @@
enum PenTool {
pen,
marker,
eraser,
highlighter,
rectangle,
ellipse,
line,
arrow,
text,
}

View File

@@ -0,0 +1,16 @@
import 'package:json_annotation/json_annotation.dart';
enum InputDeviceKind {
@JsonValue('touch')
touch,
@JsonValue('mouse')
mouse,
@JsonValue('stylus')
stylus,
@JsonValue('invertedStylus')
invertedStylus,
@JsonValue('trackpad')
trackpad,
@JsonValue('unknown')
unknown,
}

View File

@@ -0,0 +1,62 @@
import 'dart:math';
/// Predefined pressure curve types.
enum PressureCurveType { linear, soft, hard, custom }
/// Maps raw pen pressure [0,1] to effective pressure [0,1] using a power curve.
///
/// - **Linear**: identity (p)
/// - **Soft**: `pow(p, 1.5)` — light touch produces small lines, needs more pressure
/// - **Hard**: `pow(p, 0.5)` — light touch already produces thick lines
/// - **Custom**: `pow(p, exponent)` where exponent is derived from [softness]
class PressureCurve {
final PressureCurveType type;
final double softness;
const PressureCurve({
this.type = PressureCurveType.linear,
this.softness = 0.5,
});
/// Predefined linear curve (identity).
static const linear = PressureCurve(type: PressureCurveType.linear);
/// Predefined soft curve — needs more pressure to ramp up.
static const soft = PressureCurve(
type: PressureCurveType.soft,
softness: 0.3,
);
/// Predefined hard curve — light touch already produces thick lines.
static const hard = PressureCurve(
type: PressureCurveType.hard,
softness: 0.7,
);
/// Maps raw pressure [0,1] to effective pressure [0,1].
double apply(double rawPressure) {
final p = rawPressure.clamp(0.0, 1.0);
switch (type) {
case PressureCurveType.linear:
return p;
case PressureCurveType.soft:
return pow(p, 1.5).toDouble();
case PressureCurveType.hard:
return pow(p, 0.5).toDouble();
case PressureCurveType.custom:
final exponent = softness * 2 + 0.2;
return pow(p, exponent).toDouble();
}
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PressureCurve &&
runtimeType == other.runtimeType &&
type == other.type &&
softness == other.softness;
@override
int get hashCode => type.hashCode ^ softness.hashCode;
}

View File

@@ -0,0 +1,62 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../models/document.dart';
import '../services/database_service.dart';
import 'note_provider.dart';
const _uuid = Uuid();
final documentListProvider =
AsyncNotifierProvider<DocumentListNotifier, List<Document>>(
DocumentListNotifier.new,
);
class DocumentListNotifier extends AsyncNotifier<List<Document>> {
Future<DatabaseService> get _db => ref.read(databaseServiceProvider.future);
@override
Future<List<Document>> build() async {
final db = await _db;
return db.getAllDocuments();
}
/// Reloads documents from the database and publishes the result to [state]
/// so the UI rebuilds. Used by pull-to-refresh.
Future<void> loadDocuments() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final db = await _db;
return db.getAllDocuments();
});
}
Future<Document> addDocument({
required String filename,
required String docType,
required String filePath,
int pageCount = 0,
}) async {
final db = await _db;
final now = DateTime.now();
final document = Document(
id: _uuid.v4(),
filename: filename,
docType: docType,
filePath: filePath,
pageCount: pageCount,
createdAt: now,
updatedAt: now,
);
await db.insertDocument(document);
state = AsyncData([document, ...state.value ?? []]);
return document;
}
Future<void> removeDocument(String id) async {
final db = await _db;
await db.deleteDocument(id);
final current = state.value ?? [];
state = AsyncData(current.where((d) => d.id != id).toList());
}
}

View File

@@ -0,0 +1,68 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../models/note.dart';
import '../services/database_service.dart';
const _uuid = Uuid();
final databaseServiceProvider = FutureProvider<DatabaseService>((ref) async {
return DatabaseService.getInstance();
});
final noteListProvider = AsyncNotifierProvider<NoteListNotifier, List<Note>>(
NoteListNotifier.new,
);
class NoteListNotifier extends AsyncNotifier<List<Note>> {
Future<DatabaseService> get _db => ref.read(databaseServiceProvider.future);
@override
Future<List<Note>> build() async {
final db = await _db;
return db.getAllNotes();
}
/// Reloads notes from the database and publishes the result to [state] so
/// the UI rebuilds. Used by pull-to-refresh.
Future<void> loadNotes() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final db = await _db;
return db.getAllNotes();
});
}
Future<Note> createNote({String title = 'Untitled'}) async {
final db = await _db;
final now = DateTime.now();
final note = Note(
id: _uuid.v4(),
title: title,
createdAt: now,
updatedAt: now,
);
await db.insertNote(note);
state = AsyncData([note, ...state.value ?? []]);
return note;
}
Future<void> updateNote(Note note) async {
final db = await _db;
await db.updateNote(note);
final current = state.value ?? [];
state = AsyncData(current.map((n) => n.id == note.id ? note : n).toList());
}
Future<void> deleteNote(String id) async {
final db = await _db;
await db.deleteNote(id);
final current = state.value ?? [];
state = AsyncData(current.where((n) => n.id != id).toList());
}
}
final noteProvider = FutureProvider.family<Note?, String>((ref, id) async {
final db = await ref.watch(databaseServiceProvider.future);
return db.getNoteById(id);
});

View File

@@ -0,0 +1,43 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../services/ocr_service.dart';
enum OcrStatus { none, processing, done, failed }
final ocrServiceProvider = Provider<OcrService>((ref) => OcrService());
/// Tracks local OCR processing status per note ID.
///
/// This map only ever holds an entry per note that has had OCR triggered in
/// the current session. To keep it from growing without bound over a long
/// session, prune terminal/stale entries via [OcrStatusX] (e.g. remove an
/// entry once its result has been surfaced, or call [OcrStatusX.pruneOcr]
/// after a sweep). Kept as a [StateProvider] so existing call sites that
/// assign `ocrStatusProvider.notifier.state` continue to work.
final ocrStatusProvider = StateProvider<Map<String, OcrStatus>>((ref) => {});
/// Pruning helpers for [ocrStatusProvider] that keep its backing map bounded.
extension OcrStatusX on Ref {
/// Removes the tracked status for [noteId] (e.g. when its note is deleted
/// or its result has been consumed by the UI).
void clearOcr(String noteId) {
final current = read(ocrStatusProvider);
if (!current.containsKey(noteId)) return;
read(ocrStatusProvider.notifier).state = Map<String, OcrStatus>.from(
current,
)..remove(noteId);
}
/// Drops all completed/failed entries, keeping only in-flight work so the
/// map stays bounded.
void pruneOcr() {
final current = read(ocrStatusProvider);
final next = <String, OcrStatus>{
for (final entry in current.entries)
if (entry.value == OcrStatus.processing) entry.key: entry.value,
};
if (next.length != current.length) {
read(ocrStatusProvider.notifier).state = next;
}
}
}

View File

@@ -0,0 +1,93 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/document.dart';
import '../models/note.dart';
import 'note_provider.dart';
final searchQueryProvider = StateProvider<String>((ref) => '');
/// A search result that can be either a note hit or a document hit.
sealed class SearchResult {
const SearchResult();
}
class NoteSearchHit extends SearchResult {
final Note note;
final String snippet;
const NoteSearchHit({required this.note, this.snippet = ''});
}
class DocumentSearchHit extends SearchResult {
final String documentId;
final String filename;
final String filePath;
final int pageNumber;
final String snippet;
const DocumentSearchHit({
required this.documentId,
required this.filename,
required this.filePath,
required this.pageNumber,
this.snippet = '',
});
}
final searchResultsProvider = FutureProvider<List<SearchResult>>((ref) async {
final query = ref.watch(searchQueryProvider);
if (query.isEmpty) return [];
// Obtain the DB through the provider graph so this participates in
// initialization and disposal like every other consumer.
final db = await ref.watch(databaseServiceProvider.future);
// Run the note and document searches concurrently.
final searches = await Future.wait([
db.searchNotes(query),
db.searchDocuments(query),
]);
final noteHits = searches[0] as List<Note>;
final docHits = searches[1] as List<Map<String, dynamic>>;
final results = <SearchResult>[];
// Add note results.
for (final note in noteHits) {
results.add(NoteSearchHit(note: note, snippet: note.title));
}
// Resolve document metadata without an N+1 loop: collect the distinct
// document ids referenced by the hits, look each up exactly once, then
// build the result list from the cached lookups.
final docIds = <String>{
for (final hit in docHits)
if (hit['document_id'] is String) hit['document_id'] as String,
};
final docEntries = await Future.wait(
docIds.map((id) async => MapEntry(id, await db.getDocument(id))),
);
final docsById = <String, Document>{
for (final entry in docEntries)
if (entry.value != null) entry.key: entry.value!,
};
for (final hit in docHits) {
final documentId = hit['document_id'];
if (documentId is! String) continue;
final doc = docsById[documentId];
if (doc == null) continue;
final pageNumber = hit['page_number'];
final content = hit['content'];
results.add(
DocumentSearchHit(
documentId: documentId,
filename: doc.filename,
filePath: doc.filePath,
pageNumber: pageNumber is int ? pageNumber : 0,
snippet: content is String ? content : '',
),
);
}
return results;
});

View File

@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../utils/stroke_stabilizer.dart';
final settingsProvider = ChangeNotifierProvider<SettingsNotifier>(
(ref) => SettingsNotifier(),
);
/// Persists user settings across sessions using SharedPreferences.
class SettingsNotifier extends ChangeNotifier {
late SharedPreferences _prefs;
/// Completes once [_load] has assigned [_prefs]. Setters await this before
/// touching [_prefs] to avoid a LateInitializationError when invoked before
/// the fire-and-forget load from the constructor finishes.
late final Future<void> _ready;
PenTool _defaultTool = PenTool.pen;
Color _defaultColor = Colors.black;
double _defaultStrokeWidth = 2.0;
PressureCurveType _defaultPressureCurve = PressureCurveType.linear;
StabilizationLevel _defaultStabilization = StabilizationLevel.none;
ThemeMode _themeMode = ThemeMode.system;
Color _colorSchemeSeed = Colors.blue;
SettingsNotifier() {
_ready = _load();
}
PenTool get defaultTool => _defaultTool;
Color get defaultColor => _defaultColor;
double get defaultStrokeWidth => _defaultStrokeWidth;
PressureCurveType get defaultPressureCurve => _defaultPressureCurve;
StabilizationLevel get defaultStabilization => _defaultStabilization;
ThemeMode get themeMode => _themeMode;
Color get colorSchemeSeed => _colorSchemeSeed;
Future<void> setDefaultTool(PenTool tool) async {
_defaultTool = tool;
notifyListeners();
await _ready;
await _prefs.setString('defaultTool', tool.name);
}
Future<void> setDefaultColor(Color color) async {
_defaultColor = color;
notifyListeners();
await _ready;
await _prefs.setInt('defaultColor', color.toARGB32());
}
Future<void> setDefaultStrokeWidth(double width) async {
_defaultStrokeWidth = width;
notifyListeners();
await _ready;
await _prefs.setDouble('defaultStrokeWidth', width);
}
Future<void> setDefaultPressureCurve(PressureCurveType curve) async {
_defaultPressureCurve = curve;
notifyListeners();
await _ready;
await _prefs.setString('defaultPressureCurve', curve.name);
}
Future<void> setDefaultStabilization(StabilizationLevel level) async {
_defaultStabilization = level;
notifyListeners();
await _ready;
await _prefs.setString('defaultStabilization', level.name);
}
Future<void> setThemeMode(ThemeMode mode) async {
_themeMode = mode;
notifyListeners();
await _ready;
await _prefs.setString('themeMode', mode.name);
}
Future<void> setColorSchemeSeed(Color color) async {
_colorSchemeSeed = color;
notifyListeners();
await _ready;
await _prefs.setInt('colorSchemeSeed', color.toARGB32());
}
Future<void> clearAllData() async {
await _ready;
await _prefs.clear();
_defaultTool = PenTool.pen;
_defaultColor = Colors.black;
_defaultStrokeWidth = 2.0;
_defaultPressureCurve = PressureCurveType.linear;
_defaultStabilization = StabilizationLevel.none;
_themeMode = ThemeMode.system;
_colorSchemeSeed = Colors.blue;
notifyListeners();
}
Future<void> _load() async {
_prefs = await SharedPreferences.getInstance();
final toolName = _prefs.getString('defaultTool');
if (toolName != null) {
_defaultTool = PenTool.values.asNameMap()[toolName] ?? PenTool.pen;
}
final colorValue = _prefs.getInt('defaultColor');
if (colorValue != null) {
_defaultColor = Color(colorValue);
}
_defaultStrokeWidth =
_prefs.getDouble('defaultStrokeWidth') ?? _defaultStrokeWidth;
final curveName = _prefs.getString('defaultPressureCurve');
if (curveName != null) {
_defaultPressureCurve =
PressureCurveType.values.asNameMap()[curveName] ??
PressureCurveType.linear;
}
final stabName = _prefs.getString('defaultStabilization');
if (stabName != null) {
_defaultStabilization =
StabilizationLevel.values.asNameMap()[stabName] ??
StabilizationLevel.none;
}
final themeName = _prefs.getString('themeMode');
if (themeName != null) {
_themeMode = ThemeMode.values.asNameMap()[themeName] ?? ThemeMode.system;
}
final seedValue = _prefs.getInt('colorSchemeSeed');
if (seedValue != null) {
_colorSchemeSeed = Color(seedValue);
}
notifyListeners();
}
}

View File

@@ -0,0 +1,743 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/document.dart';
import '../models/note.dart';
import '../providers/document_provider.dart';
import '../providers/note_provider.dart';
import '../providers/ocr_provider.dart';
import '../services/pdf_service.dart';
import '../services/pptx_service.dart';
import 'note_editor_screen.dart';
import 'pdf_annotator_screen.dart';
import 'ppt_annotator_screen.dart';
import 'search_screen.dart';
import 'settings_screen.dart';
import 'split_view_screen.dart';
// [M1] Relative date helper — no new package dependencies.
String _formatDate(DateTime d) {
final now = DateTime.now();
final diff = now.difference(d);
if (diff.inSeconds < 60) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inDays == 1 || (diff.inDays == 0 && now.day != d.day)) {
return 'Yesterday';
}
return '${d.month}/${d.day}/${d.year} ${d.hour}:${d.minute.toString().padLeft(2, '0')}';
}
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final notesAsync = ref.watch(noteListProvider);
return Scaffold(
appBar: AppBar(
title: const Text('BadNote'),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.settings),
tooltip: 'Settings',
onPressed: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const SettingsScreen()));
},
),
IconButton(
icon: const Icon(Icons.picture_as_pdf),
tooltip: 'Import PDF',
onPressed: () => _importPdf(context),
),
IconButton(
icon: const Icon(Icons.slideshow),
tooltip: 'Import PPT',
onPressed: () => _importPptx(context),
),
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Search',
onPressed: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const SearchScreen()));
},
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _createAndOpenNote(context, ref),
child: const Icon(Icons.add),
),
body: notesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (notes) {
final documentsAsync = ref.watch(documentListProvider);
final documents = documentsAsync.valueOrNull ?? [];
if (notes.isEmpty && documents.isEmpty) {
return _buildEmptyState(context, ref);
}
return RefreshIndicator(
onRefresh: () async {
await Future.wait([
ref.read(noteListProvider.notifier).loadNotes(),
ref.read(documentListProvider.notifier).loadDocuments(),
]);
},
child: CustomScrollView(
slivers: [
// Notes section header always shown when documents exist
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
'Notes',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
),
if (notes.isNotEmpty)
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _NoteTile(note: notes[index]),
childCount: notes.length,
),
)
else
// [M2] Per-section empty hint when documents exist but notes don't
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Center(
child: Text(
'No ink notes yet — tap + to create one',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
),
),
),
// Documents section header always shown when notes exist
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
'Recent Documents',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
),
if (documents.isNotEmpty)
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) =>
_DocumentTile(document: documents[index]),
childCount: documents.length,
),
)
else
// [M2] Per-section empty hint when notes exist but documents don't
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Center(
child: Text(
'No documents yet — import a PDF or PPT',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 80)),
],
),
);
},
),
);
}
Future<void> _createAndOpenNote(BuildContext context, WidgetRef ref) async {
final note = await ref.read(noteListProvider.notifier).createNote();
if (context.mounted) {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)));
}
}
Future<void> _importPdf(BuildContext context) async {
final pdfService = PdfService();
final filePath = await pdfService.pickPdfFile();
if (filePath != null && context.mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PdfAnnotatorScreen(filePath: filePath),
),
);
}
}
Future<void> _importPptx(BuildContext context) async {
final pptxService = PptxService();
final filePath = await pptxService.openPptxFile();
if (filePath == null || !context.mounted) return;
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Processing PPTX...')));
}
final slideImages = await pptxService.convertToImages(filePath);
final extractedText = await pptxService.extractText(filePath);
if (context.mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PptAnnotatorScreen(
filePath: filePath,
slideImagePaths: slideImages,
extractedText: extractedText.isEmpty ? null : extractedText,
),
),
);
}
}
Widget _buildEmptyState(BuildContext context, WidgetRef ref) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.edit_note,
size: 80,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 24),
Text(
'No notes yet',
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Create your first note',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 32),
FilledButton.icon(
onPressed: () => _createAndOpenNote(context, ref),
icon: const Icon(Icons.add),
label: const Text('New Note'),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => _importPdf(context),
icon: const Icon(Icons.picture_as_pdf),
label: const Text('Import PDF'),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => _importPptx(context),
icon: const Icon(Icons.slideshow),
label: const Text('Import PPT'),
),
],
),
);
}
}
class _NoteTile extends ConsumerStatefulWidget {
final Note note;
const _NoteTile({required this.note});
@override
ConsumerState<_NoteTile> createState() => _NoteTileState();
}
class _NoteTileState extends ConsumerState<_NoteTile> {
bool _hovering = false;
@override
Widget build(BuildContext context) {
final note = widget.note;
// [M1] Use relative date helper
final dateStr = _formatDate(note.updatedAt);
final ocrStatusMap = ref.watch(ocrStatusProvider);
final ocrStatus = ocrStatusMap[note.id] ?? OcrStatus.none;
final subtleColor = Theme.of(context).colorScheme.onSurfaceVariant;
// [H2] Right-click context menu via GestureDetector + MouseRegion for hover
return GestureDetector(
onSecondaryTapDown: (details) =>
_showContextMenu(context, details.globalPosition),
child: MouseRegion(
onEnter: (_) => setState(() => _hovering = true),
onExit: (_) => setState(() => _hovering = false),
child: ListTile(
title: Row(
children: [
Expanded(
child: Text(
note.title.isEmpty ? 'Untitled' : note.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
_OcrStatusBadge(status: ocrStatus),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${note.strokes.length} stroke${note.strokes.length == 1 ? '' : 's'} · $dateStr',
style: Theme.of(context).textTheme.bodySmall,
),
if (note.tags.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Wrap(
spacing: 4,
children: note.tags
.map(
(t) => Chip(
label: Text(
t,
style: const TextStyle(fontSize: 11),
),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
)
.toList(),
),
),
],
),
),
// [H2] Trailing delete button — always visible with subdued color, brighter on hover
trailing: IconButton(
icon: Icon(
Icons.delete_outline,
color: _hovering
? Theme.of(context).colorScheme.error
: subtleColor.withValues(alpha: 0.4),
),
tooltip: 'Delete note',
onPressed: () => _confirmDelete(context),
),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)),
);
},
onLongPress: () => _confirmDelete(context),
),
),
);
}
void _showContextMenu(BuildContext context, Offset position) async {
final result = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy,
position.dx + 1,
position.dy + 1,
),
items: [
PopupMenuItem(
value: 'open',
child: Row(
children: const [
Icon(Icons.edit_outlined),
SizedBox(width: 8),
Text('Open'),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(
Icons.delete_outline,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 8),
Text(
'Delete',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
),
),
],
);
if (!mounted) return;
if (result == 'open') {
Navigator.of(this.context).push(
MaterialPageRoute(builder: (_) => NoteEditorScreen(note: widget.note)),
);
} else if (result == 'delete') {
_confirmDelete(this.context);
}
}
void _confirmDelete(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete note?'),
content: Text(
'Delete "${widget.note.title.isEmpty ? 'Untitled' : widget.note.title}"?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
ref.read(noteListProvider.notifier).deleteNote(widget.note.id);
Navigator.pop(ctx);
},
child: const Text('Delete'),
),
],
),
);
}
}
class _DocumentTile extends ConsumerStatefulWidget {
final Document document;
const _DocumentTile({required this.document});
@override
ConsumerState<_DocumentTile> createState() => _DocumentTileState();
}
class _DocumentTileState extends ConsumerState<_DocumentTile> {
bool _hovering = false;
@override
Widget build(BuildContext context) {
final document = widget.document;
// [M1] Use relative date helper
final dateStr = _formatDate(document.updatedAt);
final isPdf = document.docType == 'pdf';
final subtleColor = Theme.of(context).colorScheme.onSurfaceVariant;
// [H2] Right-click context menu + MouseRegion + trailing action buttons
return GestureDetector(
onSecondaryTapDown: (details) =>
_showContextMenu(context, details.globalPosition),
child: MouseRegion(
onEnter: (_) => setState(() => _hovering = true),
onExit: (_) => setState(() => _hovering = false),
child: ListTile(
leading: Icon(
isPdf ? Icons.picture_as_pdf : Icons.slideshow,
color: isPdf ? Colors.red : Colors.orange,
),
title: Text(
document.filename,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${document.docType.toUpperCase()} · ${document.pageCount} pages · $dateStr',
style: Theme.of(context).textTheme.bodySmall,
),
// [H2] Trailing row: split-view (PDF only) + remove
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isPdf)
IconButton(
icon: Icon(
Icons.vertical_split,
color: _hovering
? Theme.of(context).colorScheme.primary
: subtleColor.withValues(alpha: 0.4),
),
tooltip: 'Open in Split View',
onPressed: () => _openSplitView(context),
),
IconButton(
icon: Icon(
Icons.delete_outline,
color: _hovering
? Theme.of(context).colorScheme.error
: subtleColor.withValues(alpha: 0.4),
),
tooltip: 'Remove document',
onPressed: () => _confirmDelete(context),
),
],
),
// [L2] Routing bug fix: route by docType
onTap: () => _openDocument(context),
onLongPress: () => _showDocumentMenu(context),
),
),
);
}
// [L2] Route by docType: pdf → PdfAnnotatorScreen, ppt/pptx → PptAnnotatorScreen
Future<void> _openDocument(BuildContext context) async {
final document = widget.document;
final isPdf = document.docType == 'pdf';
if (isPdf) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PdfAnnotatorScreen(filePath: document.filePath),
),
);
} else {
// PPT/PPTX: convert to images then push PptAnnotatorScreen
if (mounted) {
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Processing presentation...')),
);
}
final pptxService = PptxService();
final slideImages = await pptxService.convertToImages(document.filePath);
final extractedText = await pptxService.extractText(document.filePath);
if (!mounted) return;
if (slideImages.isEmpty) {
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Could not open presentation.')),
);
return;
}
Navigator.of(this.context).push(
MaterialPageRoute(
builder: (_) => PptAnnotatorScreen(
filePath: document.filePath,
slideImagePaths: slideImages,
extractedText: extractedText.isEmpty ? null : extractedText,
),
),
);
}
}
void _openSplitView(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SplitViewScreen(
filePath: widget.document.filePath,
documentId: widget.document.id,
),
),
);
}
void _showContextMenu(BuildContext context, Offset position) async {
final isPdf = widget.document.docType == 'pdf';
final result = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy,
position.dx + 1,
position.dy + 1,
),
items: [
PopupMenuItem(
value: 'open',
child: Row(
children: const [
Icon(Icons.open_in_new),
SizedBox(width: 8),
Text('Open'),
],
),
),
if (isPdf)
PopupMenuItem(
value: 'split',
child: Row(
children: const [
Icon(Icons.vertical_split),
SizedBox(width: 8),
Text('Open in Split View'),
],
),
),
PopupMenuItem(
value: 'remove',
child: Row(
children: [
Icon(
Icons.delete_outline,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 8),
Text(
'Remove',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
),
),
],
);
if (!mounted) return;
if (result == 'open') {
_openDocument(this.context);
} else if (result == 'split') {
_openSplitView(this.context);
} else if (result == 'remove') {
_confirmDelete(this.context);
}
}
void _showDocumentMenu(BuildContext context) {
final isPdf = widget.document.docType == 'pdf';
showModalBottomSheet(
context: context,
builder: (ctx) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (isPdf)
ListTile(
leading: const Icon(Icons.vertical_split),
title: const Text('Open in Split View'),
subtitle: const Text('PDF reference + scratchpad'),
onTap: () {
Navigator.of(ctx).pop();
_openSplitView(context);
},
),
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.red),
title: const Text(
'Remove document',
style: TextStyle(color: Colors.red),
),
onTap: () {
Navigator.of(ctx).pop();
_confirmDelete(context);
},
),
],
),
);
},
);
}
void _confirmDelete(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Remove document?'),
content: Text(
'Remove "${widget.document.filename}" from recent documents?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
ref
.read(documentListProvider.notifier)
.removeDocument(widget.document.id);
Navigator.pop(ctx);
},
child: const Text('Remove'),
),
],
),
);
}
}
// [M3] OCR status badge with semantic theme colors and tooltips
class _OcrStatusBadge extends StatelessWidget {
final OcrStatus status;
const _OcrStatusBadge({required this.status});
@override
Widget build(BuildContext context) {
switch (status) {
case OcrStatus.none:
return const SizedBox.shrink();
case OcrStatus.processing:
return Tooltip(
message: 'Processing OCR…',
child: const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 1.5),
),
);
case OcrStatus.done:
return Tooltip(
message: 'OCR complete',
child: Icon(
Icons.check_circle,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
);
case OcrStatus.failed:
return Tooltip(
message: 'OCR failed',
child: Icon(
Icons.error_outline,
size: 16,
color: Theme.of(context).colorScheme.error,
),
);
}
}
}

View File

@@ -0,0 +1,303 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' hide UndoManager;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/ink_stroke.dart';
import '../models/note.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../providers/note_provider.dart';
import '../providers/ocr_provider.dart';
import '../services/undo_manager.dart';
import '../utils/stroke_stabilizer.dart';
import '../widgets/annotation_toolbar.dart';
import '../widgets/ink_canvas.dart';
class NoteEditorScreen extends ConsumerStatefulWidget {
final Note? note;
const NoteEditorScreen({super.key, this.note});
@override
ConsumerState<NoteEditorScreen> createState() => _NoteEditorScreenState();
}
class _NoteEditorScreenState extends ConsumerState<NoteEditorScreen> {
final UndoManager _undoManager = UndoManager();
PenTool _currentTool = PenTool.pen;
Color _currentColor = Colors.black;
double _currentStrokeWidth = 2.0;
bool _filled = false;
String _title = 'Untitled';
final TextEditingController _titleController = TextEditingController();
PressureCurveType _pressureCurveType = PressureCurveType.linear;
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
final TransformationController _zoomController = TransformationController();
double _zoomLevel = 1.0;
bool _isDirty = false;
Note? get _existingNote => widget.note;
PressureCurve get _pressureCurve {
switch (_pressureCurveType) {
case PressureCurveType.linear:
return PressureCurve.linear;
case PressureCurveType.soft:
return PressureCurve.soft;
case PressureCurveType.hard:
return PressureCurve.hard;
case PressureCurveType.custom:
return const PressureCurve(type: PressureCurveType.custom);
}
}
@override
void initState() {
super.initState();
if (_existingNote != null) {
_title = _existingNote!.title;
for (final stroke in _existingNote!.strokes) {
_undoManager.addStroke(stroke);
}
}
_titleController.text = _title;
}
@override
void dispose() {
_titleController.dispose();
_zoomController.dispose();
super.dispose();
}
void _onStrokeComplete(InkStroke stroke) {
setState(() {
_undoManager.addStroke(stroke);
_isDirty = true;
});
}
void _onErase(String strokeId, List<InkStroke> replacements) {
setState(() {
final original = _undoManager.currentStrokes
.where((s) => s.id == strokeId)
.firstOrNull;
if (original != null) {
_undoManager.removeStroke(original, replacements: replacements);
_isDirty = true;
}
});
}
void _undo() {
setState(() {
_undoManager.undo();
_isDirty = true;
});
}
void _redo() {
setState(() {
_undoManager.redo();
_isDirty = true;
});
}
Future<void> _save() async {
final notifier = ref.read(noteListProvider.notifier);
final now = DateTime.now();
Note savedNote;
if (_existingNote != null) {
final updated = _existingNote!.copyWith(
title: _title,
strokes: _undoManager.currentStrokes.toList(),
updatedAt: now,
);
await notifier.updateNote(updated);
savedNote = updated;
} else {
final note = await notifier.createNote(title: _title);
final updated = note.copyWith(
strokes: _undoManager.currentStrokes.toList(),
);
await notifier.updateNote(updated);
savedNote = updated;
}
if (!mounted) return;
setState(() {
_isDirty = false;
});
_runLocalOcr(savedNote);
}
/// Run local OCR and index results for search.
void _runLocalOcr(Note note) {
final noteId = note.id;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
noteId: OcrStatus.processing,
};
ref
.read(ocrServiceProvider)
.processNote(note)
.then((_) {
if (!mounted) return;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
noteId: OcrStatus.done,
};
})
.catchError((_) {
if (!mounted) return;
ref.read(ocrStatusProvider.notifier).state = {
...ref.read(ocrStatusProvider),
noteId: OcrStatus.failed,
};
});
}
void _zoomIn() {
final newLevel = (_zoomLevel + 0.25).clamp(0.5, 5.0);
_applyZoom(newLevel);
}
void _zoomOut() {
final newLevel = (_zoomLevel - 0.25).clamp(0.5, 5.0);
_applyZoom(newLevel);
}
void _zoomReset() {
_applyZoom(1.0);
}
void _applyZoom(double level) {
setState(() => _zoomLevel = level);
_zoomController.value = Matrix4.diagonal3Values(level, level, 1.0);
}
Future<void> _saveAndNotify() async {
await _save();
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Saved')));
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, _) {
if (didPop && _isDirty) _save();
},
child: CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.keyZ, control: true): _undo,
const SingleActivator(LogicalKeyboardKey.keyY, control: true): _redo,
const SingleActivator(
LogicalKeyboardKey.keyZ,
control: true,
shift: true,
): _redo,
SingleActivator(LogicalKeyboardKey.keyS, control: true):
_saveAndNotify,
},
child: Focus(
autofocus: true,
child: Scaffold(
appBar: AppBar(
title: SizedBox(
height: 40,
child: TextField(
controller: _titleController,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
decoration: InputDecoration(
border: InputBorder.none,
hintText: 'Note title...',
contentPadding: const EdgeInsets.symmetric(vertical: 8),
suffix: _isDirty
? const Text(
'',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
)
: null,
),
onChanged: (value) {
_title = value;
setState(() => _isDirty = true);
},
),
),
actions: [
IconButton(
icon: const Icon(Icons.check),
tooltip: 'Save',
onPressed: _saveAndNotify,
),
],
),
body: Column(
children: [
AnnotationToolbar(
currentTool: _currentTool,
currentColor: _currentColor,
currentStrokeWidth: _currentStrokeWidth,
filled: _filled,
pressureCurveType: _pressureCurveType,
stabilizationLevel: _stabilizationLevel,
canUndo: _undoManager.canUndo,
canRedo: _undoManager.canRedo,
onToolChanged: (tool) => setState(() => _currentTool = tool),
onColorChanged: (color) =>
setState(() => _currentColor = color),
onStrokeWidthChanged: (w) =>
setState(() => _currentStrokeWidth = w),
onFilledChanged: (f) => setState(() => _filled = f),
onPressureCurveChanged: (v) =>
setState(() => _pressureCurveType = v),
onStabilizationChanged: (v) =>
setState(() => _stabilizationLevel = v),
onUndo: _undo,
onRedo: _redo,
onZoomIn: _zoomIn,
onZoomOut: _zoomOut,
onZoomFitWidth: _zoomReset,
zoomLabel: '${(_zoomLevel * 100).round()}%',
),
Expanded(
child: InteractiveViewer(
transformationController: _zoomController,
minScale: 0.5,
maxScale: 5.0,
child: InkCanvas(
strokes: _undoManager.currentStrokes,
onStrokeComplete: _onStrokeComplete,
onErase: _onErase,
tool: _currentTool,
color: _currentColor,
strokeWidth: _currentStrokeWidth,
pressureCurve: _pressureCurve,
stabilizationLevel: _stabilizationLevel,
filled: _filled,
),
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,981 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' hide UndoManager;
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:uuid/uuid.dart';
import '../models/bookmark.dart';
import '../models/document.dart';
import '../models/ink_stroke.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../services/camera_service.dart';
import '../services/database_service.dart';
import '../services/pdf_service.dart';
import '../services/thumbnail_service.dart';
import '../services/undo_manager.dart';
import '../utils/stroke_stabilizer.dart';
import '../widgets/annotation_toolbar.dart';
import '../widgets/ink_canvas.dart';
import '../widgets/page_thumbnail_sidebar.dart';
import '../widgets/pdf_annotation_layer.dart';
import 'pdf_text_search.dart';
import 'split_view_screen.dart';
const _uuid = Uuid();
/// Actions available in the AppBar overflow menu.
enum _OverflowAction { pageManagement, cameraInsert, export }
/// Full-screen PDF viewer with ink annotation overlay.
///
/// Displays a PDF page-by-page with a transparent [PdfAnnotationLayer]
/// on top for pen/marker/eraser annotations. Annotations are stored
/// per page in normalized [0, 1] coordinates and exported via [PdfService].
/// Annotations and bookmarks are persisted to the database.
class PdfAnnotatorScreen extends StatefulWidget {
final String filePath;
final int initialPage;
const PdfAnnotatorScreen({
super.key,
required this.filePath,
this.initialPage = 0,
});
@override
State<PdfAnnotatorScreen> createState() => _PdfAnnotatorScreenState();
}
class _PdfAnnotatorScreenState extends State<PdfAnnotatorScreen> {
final PdfService _pdfService = PdfService();
final CameraService _cameraService = CameraService();
final PdfViewerController _viewerController = PdfViewerController();
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
int _currentPage = 0;
int _pageCount = 0;
int _pdfMutationVersion = 0;
String _fileName = '';
PenTool _currentTool = PenTool.pen;
Color _currentColor = Colors.black;
double _currentStrokeWidth = 2.0;
bool _filled = false;
PressureCurveType _pressureCurveType = PressureCurveType.linear;
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
InteractionMode _interactionMode = InteractionMode.draw;
double _zoomLevel = 1.0;
bool _showThumbnails = false;
String? _currentDocumentId;
final Map<int, UndoManager> _undoManagers = {};
final Map<int, List<InkStroke>> _annotations = {};
List<Bookmark> _bookmarks = [];
@override
void initState() {
super.initState();
_currentPage = widget.initialPage;
_loadPdfInfo();
}
Future<void> _loadPdfInfo() async {
final info = await _pdfService.getPdfInfo(widget.filePath);
final count = await _pdfService.getPageCount(widget.filePath);
if (mounted) {
setState(() {
_fileName = info['fileName'] as String;
_pageCount = count;
});
await _ensureDocumentExists();
await _loadAllAnnotations();
await _loadBookmarks();
}
}
Future<void> _ensureDocumentExists() async {
final db = await DatabaseService.getInstance();
final existing = await db.getDocumentByPath(widget.filePath);
if (existing == null) {
final now = DateTime.now();
final newDoc = Document(
id: _uuid.v4(),
filename: _fileName,
docType: 'pdf',
filePath: widget.filePath,
pageCount: _pageCount,
createdAt: now,
updatedAt: now,
);
await db.insertDocument(newDoc);
_currentDocumentId = newDoc.id;
} else {
_currentDocumentId = existing.id;
}
}
Future<void> _loadAllAnnotations() async {
if (_currentDocumentId == null) return;
final db = await DatabaseService.getInstance();
for (int i = 0; i < _pageCount; i++) {
final json = await db.getAnnotations(_currentDocumentId!, i);
if (json != null && json.isNotEmpty) {
final List<dynamic> list = jsonDecode(json) as List<dynamic>;
_annotations[i] = list
.map((s) => InkStroke.fromJson(s as Map<String, dynamic>))
.toList();
_undoManagers[i] = UndoManager();
for (final stroke in _annotations[i]!) {
_undoManagers[i]!.addStroke(stroke);
}
}
}
if (mounted) setState(() {});
}
void _saveCurrentPageAnnotations({int? page}) async {
if (_currentDocumentId == null) return;
// Capture the page index and serialize its strokes SYNCHRONOUSLY, before
// any await. Otherwise a concurrent navigation could change _currentPage
// while this is suspended, causing the wrong page's data to be saved.
final targetPage = page ?? _currentPage;
final documentId = _currentDocumentId!;
final strokesJson = jsonEncode(
_annotations[targetPage]?.map((s) => s.toJson()).toList() ?? [],
);
final db = await DatabaseService.getInstance();
await db.saveAnnotations(documentId, targetPage, strokesJson);
}
void _onPageChanged(int page) {
// Save the page we are leaving, not the one we are navigating to.
_saveCurrentPageAnnotations(page: _currentPage);
setState(() {
_currentPage = page;
});
}
UndoManager _getUndoManager(int page) {
return _undoManagers.putIfAbsent(page, UndoManager.new);
}
List<InkStroke> _getCurrentStrokes() {
return _annotations[_currentPage] ?? [];
}
void _onStrokeComplete(InkStroke stroke) {
setState(() {
_annotations.putIfAbsent(_currentPage, () => []);
_annotations[_currentPage]!.add(stroke);
_getUndoManager(_currentPage).addStroke(stroke);
});
_saveCurrentPageAnnotations();
}
void _onErase(String strokeId, List<InkStroke> replacements) {
setState(() {
final pageStrokes = _annotations[_currentPage];
if (pageStrokes == null) return;
final original = pageStrokes.where((s) => s.id == strokeId).firstOrNull;
if (original != null) {
_getUndoManager(
_currentPage,
).removeStroke(original, replacements: replacements);
_annotations[_currentPage] = _getUndoManager(
_currentPage,
).currentStrokes.toList();
}
});
_saveCurrentPageAnnotations();
}
void _undo() {
setState(() {
_getUndoManager(_currentPage).undo();
_annotations[_currentPage] = _getUndoManager(
_currentPage,
).currentStrokes.toList();
});
_saveCurrentPageAnnotations();
}
void _redo() {
setState(() {
_getUndoManager(_currentPage).redo();
_annotations[_currentPage] = _getUndoManager(
_currentPage,
).currentStrokes.toList();
});
_saveCurrentPageAnnotations();
}
// -- Bookmarks --
bool get _isCurrentPageBookmarked =>
_bookmarks.any((b) => b.pageNumber == _currentPage);
Future<void> _loadBookmarks() async {
if (_currentDocumentId == null) return;
final db = await DatabaseService.getInstance();
final bookmarks = await db.getBookmarks(_currentDocumentId!);
if (mounted) {
setState(() {
_bookmarks = bookmarks;
});
}
}
Future<void> _toggleBookmark() async {
if (_currentDocumentId == null) return;
final db = await DatabaseService.getInstance();
if (_isCurrentPageBookmarked) {
final existing = _bookmarks.firstWhere(
(b) => b.pageNumber == _currentPage,
);
await db.deleteBookmark(existing.id);
setState(() {
_bookmarks.removeWhere((b) => b.id == existing.id);
});
} else {
final label = await _showBookmarkDialog();
if (label == null) return;
final bookmark = Bookmark(
id: _uuid.v4(),
documentId: _currentDocumentId!,
pageNumber: _currentPage,
label: label,
createdAt: DateTime.now(),
);
await db.insertBookmark(bookmark);
setState(() {
_bookmarks.add(bookmark);
_bookmarks.sort((a, b) => a.pageNumber.compareTo(b.pageNumber));
});
}
}
Future<String?> _showBookmarkDialog() async {
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Add Bookmark'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
hintText: 'Label (optional)',
labelText: 'Bookmark label',
),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(controller.text),
child: const Text('Add'),
),
],
);
},
);
}
Future<void> _deleteBookmark(Bookmark bookmark) async {
final db = await DatabaseService.getInstance();
await db.deleteBookmark(bookmark.id);
setState(() {
_bookmarks.removeWhere((b) => b.id == bookmark.id);
});
}
void _jumpToPage(int page) {
_saveCurrentPageAnnotations();
_viewerController.jumpToPage(page + 1);
}
// -- Zoom --
void _zoomIn() {
final newLevel = (_zoomLevel + 0.25).clamp(0.5, 5.0);
_viewerController.zoomLevel = newLevel;
setState(() => _zoomLevel = newLevel);
}
void _zoomOut() {
final newLevel = (_zoomLevel - 0.25).clamp(0.5, 5.0);
_viewerController.zoomLevel = newLevel;
setState(() => _zoomLevel = newLevel);
}
void _zoomFitWidth() {
_viewerController.zoomLevel = 1.0;
setState(() => _zoomLevel = 1.0);
}
// -- Search --
void _openSearch() {
showDialog(
context: context,
builder: (_) => PdfTextSearchDialog(viewerController: _viewerController),
);
}
// -- Export --
Future<void> _exportPdf() async {
try {
final outputPath = await _pdfService.exportAnnotatedPdf(
widget.filePath,
_annotations,
);
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Exported to: $outputPath')));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Export failed: $e')));
}
}
}
// -- Page Management --
void _showPageManagementSheet() {
showModalBottomSheet(
context: context,
builder: (context) {
final canDelete = _pageCount > 1;
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.rotate_right),
title: const Text('Rotate Page 90\u00B0'),
subtitle: Text('Page ${_currentPage + 1}'),
onTap: () {
Navigator.of(context).pop();
_rotateCurrentPage();
},
),
ListTile(
leading: Icon(
Icons.delete_outline,
color: canDelete ? null : Colors.grey,
),
title: Text(
'Delete Page',
style: TextStyle(color: canDelete ? null : Colors.grey),
),
subtitle: Text(
canDelete
? 'Page ${_currentPage + 1}'
: 'Cannot delete the only page',
),
enabled: canDelete,
onTap: canDelete
? () {
Navigator.of(context).pop();
_deleteCurrentPage();
}
: null,
),
ListTile(
leading: const Icon(Icons.note_add_outlined),
title: const Text('Insert Blank Page After Current'),
subtitle: Text('After page ${_currentPage + 1}'),
onTap: () {
Navigator.of(context).pop();
_insertBlankPageAfterCurrent();
},
),
],
),
);
},
);
}
/// Transform a stroke's normalized [0,1] points to match a 90° clockwise
/// page rotation: a point at (x, y) maps to (1 - y, x). Used to keep
/// existing annotations glued to the page content after the page itself is
/// physically rotated (PDF /Rotate).
InkStroke _rotateStroke90CW(InkStroke stroke) {
return stroke.copyWith(
points: stroke.points
.map((p) => p.copyWith(x: 1.0 - p.y, y: p.x))
.toList(),
);
}
Future<void> _rotateCurrentPage() async {
final rotatedPage = _currentPage;
final success = await _pdfService.rotatePage(widget.filePath, rotatedPage);
if (!success || !mounted) return;
// Invalidate thumbnail for the rotated page.
if (_currentDocumentId != null) {
await ThumbnailService.invalidatePage(_currentDocumentId!, rotatedPage);
}
setState(() {
_pdfMutationVersion++;
// The page is physically rotated 90° CW, so transform existing stored
// annotations the same way to keep them aligned with the page content.
// New strokes drawn afterwards are already captured in the rotated frame.
final existing = _annotations[rotatedPage];
if (existing != null && existing.isNotEmpty) {
_annotations[rotatedPage] = existing.map(_rotateStroke90CW).toList();
// Undo history holds pre-rotation coordinates; reset it for this page
// so undo/redo cannot reintroduce misaligned strokes.
_undoManagers.remove(rotatedPage);
}
});
// Persist the transformed annotations for the rotated page.
_saveCurrentPageAnnotations(page: rotatedPage);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Rotated page ${_currentPage + 1}')),
);
}
}
Future<void> _deleteCurrentPage() async {
// Confirm.
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Page'),
content: Text(
'Delete page ${_currentPage + 1}? This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed != true) return;
final success = await _pdfService.deletePage(widget.filePath, _currentPage);
if (!success || !mounted) return;
if (_currentDocumentId != null) {
final db = await DatabaseService.getInstance();
// Delete annotation/bookmark/ocr data for the removed page.
await db.deletePageData(_currentDocumentId!, _currentPage);
// Remap higher-indexed data down by 1.
await db.remapAnnotationsAfterDelete(_currentDocumentId!, _currentPage);
await db.remapBookmarksAfterDelete(_currentDocumentId!, _currentPage);
// Update stored page count.
final newCount = _pageCount - 1;
await db.updateDocumentPageCount(_currentDocumentId!, newCount);
// Invalidate all thumbnails (page indices shifted).
await ThumbnailService.invalidateAll(_currentDocumentId!);
}
// Shift in-memory annotations down.
final newAnnotations = <int, List<InkStroke>>{};
for (final entry in _annotations.entries) {
if (entry.key < _currentPage) {
newAnnotations[entry.key] = entry.value;
} else if (entry.key > _currentPage) {
newAnnotations[entry.key - 1] = entry.value;
}
// entry.key == _currentPage is dropped.
}
_annotations
..clear()
..addAll(newAnnotations);
// Shift undo managers.
final newUndoManagers = <int, UndoManager>{};
for (final entry in _undoManagers.entries) {
if (entry.key < _currentPage) {
newUndoManagers[entry.key] = entry.value;
} else if (entry.key > _currentPage) {
newUndoManagers[entry.key - 1] = entry.value;
}
}
_undoManagers
..clear()
..addAll(newUndoManagers);
// Shift bookmarks in memory.
_bookmarks.removeWhere((b) => b.pageNumber == _currentPage);
for (int i = 0; i < _bookmarks.length; i++) {
if (_bookmarks[i].pageNumber > _currentPage) {
_bookmarks[i] = Bookmark(
id: _bookmarks[i].id,
documentId: _bookmarks[i].documentId,
pageNumber: _bookmarks[i].pageNumber - 1,
label: _bookmarks[i].label,
color: _bookmarks[i].color,
createdAt: _bookmarks[i].createdAt,
);
}
}
setState(() {
_pageCount = _pageCount - 1;
if (_currentPage >= _pageCount) {
_currentPage = _pageCount - 1;
}
_pdfMutationVersion++;
});
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Page deleted')));
}
}
Future<void> _insertBlankPageAfterCurrent() async {
final success = await _pdfService.insertBlankPage(
widget.filePath,
_currentPage,
);
if (!success || !mounted) return;
final insertedIndex = _currentPage + 1;
if (_currentDocumentId != null) {
final db = await DatabaseService.getInstance();
await db.remapAnnotationsAfterInsert(_currentDocumentId!, insertedIndex);
await db.remapBookmarksAfterInsert(_currentDocumentId!, insertedIndex);
final newCount = _pageCount + 1;
await db.updateDocumentPageCount(_currentDocumentId!, newCount);
await ThumbnailService.invalidateAll(_currentDocumentId!);
}
// Shift in-memory annotations up by 1 for pages >= insertedIndex.
final newAnnotations = <int, List<InkStroke>>{};
for (final entry in _annotations.entries) {
if (entry.key < insertedIndex) {
newAnnotations[entry.key] = entry.value;
} else {
newAnnotations[entry.key + 1] = entry.value;
}
}
_annotations
..clear()
..addAll(newAnnotations);
final newUndoManagers = <int, UndoManager>{};
for (final entry in _undoManagers.entries) {
if (entry.key < insertedIndex) {
newUndoManagers[entry.key] = entry.value;
} else {
newUndoManagers[entry.key + 1] = entry.value;
}
}
_undoManagers
..clear()
..addAll(newUndoManagers);
// Shift bookmarks in memory.
for (int i = 0; i < _bookmarks.length; i++) {
if (_bookmarks[i].pageNumber >= insertedIndex) {
_bookmarks[i] = Bookmark(
id: _bookmarks[i].id,
documentId: _bookmarks[i].documentId,
pageNumber: _bookmarks[i].pageNumber + 1,
label: _bookmarks[i].label,
color: _bookmarks[i].color,
createdAt: _bookmarks[i].createdAt,
);
}
}
setState(() {
_pageCount = _pageCount + 1;
_pdfMutationVersion++;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Blank page inserted after page ${_currentPage + 1}'),
),
);
}
}
// -- Camera Insert --
Future<void> _showCameraInsertDialog() async {
final source = await showDialog<String>(
context: context,
builder: (context) => SimpleDialog(
title: const Text('Insert Image'),
children: [
SimpleDialogOption(
onPressed: () => Navigator.of(context).pop('camera'),
child: const ListTile(
leading: Icon(Icons.camera_alt),
title: Text('Camera'),
),
),
SimpleDialogOption(
onPressed: () => Navigator.of(context).pop('gallery'),
child: const ListTile(
leading: Icon(Icons.photo_library),
title: Text('Gallery'),
),
),
],
),
);
if (source == null || !mounted) return;
final String? imagePath;
if (source == 'camera') {
imagePath = await _cameraService.capturePhoto();
} else {
imagePath = await _cameraService.pickFromGallery();
}
if (imagePath == null || !mounted) return;
final result = await _pdfService.insertImageOnPage(
widget.filePath,
_currentPage,
imagePath,
);
if (result != null && mounted) {
if (_currentDocumentId != null) {
await ThumbnailService.invalidatePage(
_currentDocumentId!,
_currentPage,
);
}
setState(() {
_pdfMutationVersion++;
});
// Save current annotations so they overlay the image.
_saveCurrentPageAnnotations();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Image inserted on page ${_currentPage + 1}')),
);
}
} else if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Failed to insert image')));
}
}
// -- Bookmark drawer --
Widget _buildBookmarkDrawer() {
return Drawer(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Bookmarks',
style: Theme.of(context).textTheme.headlineSmall,
),
),
Expanded(
child: _bookmarks.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.bookmark_border,
size: 48,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 12),
const Text(
'No bookmarks yet',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
const Text(
'Tap the bookmark icon in the toolbar\nto bookmark the current page.',
textAlign: TextAlign.center,
),
],
),
)
: ListView.builder(
itemCount: _bookmarks.length,
itemBuilder: (context, index) {
final bookmark = _bookmarks[index];
return ListTile(
leading: CircleAvatar(
backgroundColor: Color(bookmark.color),
radius: 6,
),
title: Text(
bookmark.label.isEmpty
? 'Page ${bookmark.pageNumber + 1}'
: bookmark.label,
),
subtitle: Text('Page ${bookmark.pageNumber + 1}'),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Delete bookmark',
onPressed: () => _deleteBookmark(bookmark),
),
onTap: () {
Navigator.of(context).pop();
_jumpToPage(bookmark.pageNumber);
},
onLongPress: () => _deleteBookmark(bookmark),
);
},
),
),
],
),
);
}
// -- UI --
@override
Widget build(BuildContext context) {
final undoManager = _getUndoManager(_currentPage);
return Scaffold(
key: _scaffoldKey,
appBar: AppBar(
title: Text(_fileName, style: const TextStyle(fontSize: 16)),
actions: [
IconButton(
icon: const Icon(Icons.vertical_split),
tooltip: 'Open in Split View',
onPressed: () {
if (_currentDocumentId == null) return;
_saveCurrentPageAnnotations();
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SplitViewScreen(
filePath: widget.filePath,
documentId: _currentDocumentId!,
),
),
);
},
),
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Search in PDF (Ctrl+F)',
onPressed: _openSearch,
),
IconButton(
icon: Icon(
_isCurrentPageBookmarked ? Icons.bookmark : Icons.bookmark_border,
),
tooltip: 'Toggle bookmark',
onPressed: _toggleBookmark,
),
IconButton(
icon: const Icon(Icons.menu_book),
tooltip: 'Bookmarks',
onPressed: () => _scaffoldKey.currentState?.openEndDrawer(),
),
IconButton(
icon: Icon(
_showThumbnails
? Icons.view_sidebar
: Icons.view_sidebar_outlined,
),
tooltip: 'Toggle page thumbnails',
onPressed: () => setState(() => _showThumbnails = !_showThumbnails),
),
PopupMenuButton<_OverflowAction>(
icon: const Icon(Icons.more_vert),
tooltip: 'More actions',
onSelected: (action) {
switch (action) {
case _OverflowAction.pageManagement:
_showPageManagementSheet();
case _OverflowAction.cameraInsert:
_showCameraInsertDialog();
case _OverflowAction.export:
_exportPdf();
}
},
itemBuilder: (context) => const [
PopupMenuItem(
value: _OverflowAction.pageManagement,
child: ListTile(
leading: Icon(Icons.pages),
title: Text('Page Management'),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuItem(
value: _OverflowAction.cameraInsert,
child: ListTile(
leading: Icon(Icons.camera_alt),
title: Text('Insert Image'),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuItem(
value: _OverflowAction.export,
child: ListTile(
leading: Icon(Icons.save_alt),
title: Text('Export PDF'),
contentPadding: EdgeInsets.zero,
),
),
],
),
],
),
endDrawer: _buildBookmarkDrawer(),
body: CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.keyZ, control: true): _undo,
const SingleActivator(LogicalKeyboardKey.keyY, control: true): _redo,
const SingleActivator(
LogicalKeyboardKey.keyZ,
control: true,
shift: true,
): _redo,
const SingleActivator(LogicalKeyboardKey.keyF, control: true):
_openSearch,
const SingleActivator(LogicalKeyboardKey.keyS, control: true):
_saveCurrentPageAnnotations,
const SingleActivator(LogicalKeyboardKey.escape): () {
if (_interactionMode != InteractionMode.navigate) {
setState(() => _interactionMode = InteractionMode.navigate);
}
},
},
child: Focus(
autofocus: true,
child: Column(
children: [
AnnotationToolbar(
currentTool: _currentTool,
currentColor: _currentColor,
currentStrokeWidth: _currentStrokeWidth,
filled: _filled,
pressureCurveType: _pressureCurveType,
stabilizationLevel: _stabilizationLevel,
canUndo: undoManager.canUndo,
canRedo: undoManager.canRedo,
onToolChanged: (tool) => setState(() => _currentTool = tool),
onColorChanged: (color) =>
setState(() => _currentColor = color),
onStrokeWidthChanged: (w) =>
setState(() => _currentStrokeWidth = w),
onFilledChanged: (f) => setState(() => _filled = f),
onPressureCurveChanged: (v) =>
setState(() => _pressureCurveType = v),
onStabilizationChanged: (v) =>
setState(() => _stabilizationLevel = v),
onUndo: _undo,
onRedo: _redo,
onPreviousPage: _currentPage > 0
? () => _viewerController.previousPage()
: null,
onNextPage: _currentPage < _pageCount - 1
? () => _viewerController.nextPage()
: null,
pageInfo: '${_currentPage + 1} / $_pageCount',
interactionMode: _interactionMode,
onInteractionModeChanged: (mode) =>
setState(() => _interactionMode = mode),
onZoomIn: _zoomIn,
onZoomOut: _zoomOut,
onZoomFitWidth: _zoomFitWidth,
zoomLabel: '${(_zoomLevel * 100).round()}%',
),
Expanded(
child: Row(
children: [
if (_showThumbnails && _currentDocumentId != null)
PageThumbnailSidebar(
documentId: _currentDocumentId!,
filePath: widget.filePath,
pageCount: _pageCount,
currentPage: _currentPage,
onPageTap: _jumpToPage,
bookmarkedPages: _bookmarks
.map((b) => b.pageNumber)
.toSet(),
),
Expanded(
child: Stack(
children: [
SfPdfViewer.file(
File(widget.filePath),
key: ValueKey('pdf-$_pdfMutationVersion'),
controller: _viewerController,
initialPageNumber: _currentPage + 1,
onPageChanged: (PdfPageChangedDetails details) {
_onPageChanged(details.newPageNumber - 1);
},
),
Positioned.fill(
child: _interactionMode == InteractionMode.navigate
? IgnorePointer(
child: PdfAnnotationLayer(
strokes: _getCurrentStrokes(),
onStrokeComplete: _onStrokeComplete,
onErase: _onErase,
tool: _currentTool,
color: _currentColor,
strokeWidth: _currentStrokeWidth,
filled: _filled,
interactionMode: _interactionMode,
),
)
: PdfAnnotationLayer(
strokes: _getCurrentStrokes(),
onStrokeComplete: _onStrokeComplete,
onErase: _onErase,
tool: _currentTool,
color: _currentColor,
strokeWidth: _currentStrokeWidth,
filled: _filled,
interactionMode: _interactionMode,
),
),
],
),
),
],
),
),
],
),
),
),
);
}
@override
void dispose() {
_saveCurrentPageAnnotations();
_viewerController.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
/// A dialog for searching text within a PDF using SfPdfViewer's built-in search.
class PdfTextSearchDialog extends StatefulWidget {
final PdfViewerController viewerController;
const PdfTextSearchDialog({super.key, required this.viewerController});
@override
State<PdfTextSearchDialog> createState() => _PdfTextSearchDialogState();
}
class _PdfTextSearchDialogState extends State<PdfTextSearchDialog> {
final TextEditingController _queryController = TextEditingController();
PdfTextSearchResult? _searchResult;
String _statusText = '';
@override
void dispose() {
_queryController.dispose();
_searchResult?.clear();
super.dispose();
}
void _search() {
final query = _queryController.text.trim();
if (query.isEmpty) return;
final result = widget.viewerController.searchText(query);
setState(() {
_searchResult = result;
_updateStatus();
});
}
void _nextMatch() {
_searchResult?.nextInstance();
_updateStatus();
}
void _previousMatch() {
_searchResult?.previousInstance();
_updateStatus();
}
void _updateStatus() {
final result = _searchResult;
if (result == null || result.totalInstanceCount == 0) {
setState(() => _statusText = 'No matches');
} else {
setState(() {
_statusText =
'${result.currentInstanceIndex} of ${result.totalInstanceCount} matches';
});
}
}
@override
Widget build(BuildContext context) {
return Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _queryController,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search in PDF...',
prefixIcon: Icon(Icons.search),
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (_) => _search(),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Search',
onPressed: _search,
),
],
),
const SizedBox(height: 12),
Row(
children: [
Text(
_statusText,
style: Theme.of(context).textTheme.bodySmall,
),
const Spacer(),
IconButton(
icon: const Icon(Icons.keyboard_arrow_up),
tooltip: 'Previous match',
onPressed:
_searchResult != null &&
_searchResult!.totalInstanceCount > 0
? _previousMatch
: null,
iconSize: 20,
),
IconButton(
icon: const Icon(Icons.keyboard_arrow_down),
tooltip: 'Next match',
onPressed:
_searchResult != null &&
_searchResult!.totalInstanceCount > 0
? _nextMatch
: null,
iconSize: 20,
),
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Clear search',
onPressed: () {
_searchResult?.clear();
setState(() {
_queryController.clear();
_searchResult = null;
_statusText = '';
});
},
iconSize: 20,
),
],
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,528 @@
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import 'package:syncfusion_flutter_pdf/pdf.dart';
import '../models/ink_stroke.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../services/undo_manager.dart';
import '../utils/stroke_stabilizer.dart';
import '../widgets/annotation_toolbar.dart';
import '../widgets/ink_canvas.dart';
/// Per-slide annotation state. The [UndoManager] is the single source of
/// truth for a slide's strokes; [strokes] reflects its current contents so
/// the live canvas and the PDF export always render what was actually drawn.
class _SlideAnnotations {
final UndoManager undoManager = UndoManager();
List<InkStroke> get strokes => undoManager.currentStrokes;
}
/// Screen that displays PPTX slides with an ink annotation overlay.
///
/// Each slide is shown as an image in a [PageView]. A transparent [InkCanvas]
/// sits on top of each slide so the user can annotate freely. Annotations are
/// stored per-slide and can be exported as a PDF.
class PptAnnotatorScreen extends StatefulWidget {
final String filePath;
final List<String> slideImagePaths;
final String? extractedText;
const PptAnnotatorScreen({
super.key,
required this.filePath,
required this.slideImagePaths,
this.extractedText,
});
@override
State<PptAnnotatorScreen> createState() => _PptAnnotatorScreenState();
}
class _PptAnnotatorScreenState extends State<PptAnnotatorScreen> {
late final PageController _pageController;
late final Map<int, _SlideAnnotations> _annotations;
int _currentPage = 0;
bool _isDrawing = false;
bool _showTextPanel = false;
// Set to true once the unsaved-annotations warning SnackBar has been shown.
bool _hasShownUnsavedWarning = false;
// Toolbar state
PenTool _currentTool = PenTool.pen;
Color _currentColor = Colors.black;
double _currentStrokeWidth = 2.0;
bool _filled = false;
PressureCurveType _pressureCurveType = PressureCurveType.linear;
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
// Derived
late final String _fileName;
late final int _slideCount;
late final String _extractedText;
PressureCurve get _pressureCurve {
switch (_pressureCurveType) {
case PressureCurveType.linear:
return PressureCurve.linear;
case PressureCurveType.soft:
return PressureCurve.soft;
case PressureCurveType.hard:
return PressureCurve.hard;
case PressureCurveType.custom:
return const PressureCurve(type: PressureCurveType.custom);
}
}
UndoManager get _currentUndoManager =>
_annotations.putIfAbsent(_currentPage, _SlideAnnotations.new).undoManager;
@override
void initState() {
super.initState();
_fileName = p.basename(widget.filePath);
_slideCount = widget.slideImagePaths.length;
_extractedText = widget.extractedText ?? '';
_pageController = PageController();
_annotations = {};
for (var i = 0; i < _slideCount; i++) {
_annotations[i] = _SlideAnnotations();
}
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
// -- Drawing callbacks --
void _onStrokeComplete(InkStroke stroke) {
setState(() {
_currentUndoManager.addStroke(stroke);
});
// Warn once per session that PPT annotations are not auto-saved.
if (!_hasShownUnsavedWarning) {
_hasShownUnsavedWarning = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"PPT ink isn't saved automatically — use Export to PDF to keep your annotations.",
),
duration: Duration(seconds: 5),
),
);
});
}
}
void _onErase(String strokeId, List<InkStroke> replacements) {
setState(() {
final original = _currentUndoManager.currentStrokes
.where((s) => s.id == strokeId)
.firstOrNull;
if (original != null) {
_currentUndoManager.removeStroke(original, replacements: replacements);
}
});
}
// -- Export --
Future<void> _exportPdf() async {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Exporting PDF...')));
try {
final bytes = await _buildPdfBytes();
if (!mounted) return;
final dir = await _getExportDir();
final baseName = p.basenameWithoutExtension(_fileName);
final outPath = p.join(dir.path, '${baseName}_annotated.pdf');
await File(outPath).writeAsBytes(bytes);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('PDF saved: $outPath')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Export failed: $e')));
}
}
Future<Directory> _getExportDir() async {
try {
final home = Platform.environment['HOME'];
if (home != null) {
final dir = Directory(p.join(home, 'Documents', 'BadNote'));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
} catch (_) {}
return Directory.current;
}
Future<Uint8List> _buildPdfBytes() async {
final doc = PdfDocument();
doc.pageSettings.margins.all = 0;
for (var i = 0; i < _slideCount; i++) {
final page = doc.pages.add();
final pageSize = page.getClientSize();
// Draw slide image
final imgPath = widget.slideImagePaths[i];
try {
final imgBytes = await File(imgPath).readAsBytes();
final bitmap = PdfBitmap(imgBytes);
final imgW = bitmap.width.toDouble();
final imgH = bitmap.height.toDouble();
final scale = min(pageSize.width / imgW, pageSize.height / imgH);
final drawW = imgW * scale;
final drawH = imgH * scale;
final offX = (pageSize.width - drawW) / 2;
final offY = (pageSize.height - drawH) / 2;
final imgRect = Rect.fromLTWH(offX, offY, drawW, drawH);
page.graphics.drawImage(bitmap, imgRect);
// Draw ink strokes
final annots = _annotations[i];
if (annots != null && annots.strokes.isNotEmpty) {
// KNOWN LIMITATION: strokes are captured in the live viewer's
// full-fill pixel space (the InkCanvas is Positioned.fill over the
// whole slide area, while the slide image is BoxFit.contain inside
// it). The scale below is derived from the PDF page layout, not the
// live widget size, so exported ink can be misaligned/scaled wrong.
// A correct fix normalizes strokes to [0,1] of the *rendered image
// rect* at capture time (mirroring PdfAnnotationLayer) and maps that
// to the PDF draw rect here. Requires on-device visual verification.
final imgAspect = imgW / imgH;
final pageAspect = pageSize.width / pageSize.height;
double widgetW, widgetH;
if (imgAspect > pageAspect) {
widgetW = pageSize.width;
widgetH = pageSize.width / imgAspect;
} else {
widgetH = pageSize.height;
widgetW = pageSize.height * imgAspect;
}
final scaleX = drawW / widgetW;
final scaleY = drawH / widgetH;
for (final stroke in annots.strokes) {
if (stroke.tool == PenTool.eraser) continue;
if (stroke.points.length < 2) continue;
final r = (stroke.color >> 16) & 0xFF;
final g = (stroke.color >> 8) & 0xFF;
final b = stroke.color & 0xFF;
final pdfColor = PdfColor(r, g, b);
final path = PdfPath();
path.startFigure();
for (var j = 0; j < stroke.points.length - 1; j++) {
final pt1 = stroke.points[j];
final pt2 = stroke.points[j + 1];
path.addLine(
Offset(offX + pt1.x * scaleX, offY + pt1.y * scaleY),
Offset(offX + pt2.x * scaleX, offY + pt2.y * scaleY),
);
}
page.graphics.drawPath(
path,
pen: PdfPen(pdfColor, width: stroke.strokeWidth),
);
}
}
} catch (_) {
page.graphics.drawRectangle(
brush: PdfSolidBrush(PdfColor(230, 230, 230)),
bounds: Rect.fromLTWH(0, 0, pageSize.width, pageSize.height),
);
}
}
final bytes = await doc.save();
doc.dispose();
return Uint8List.fromList(bytes);
}
// -- UI --
@override
Widget build(BuildContext context) {
// No slides to annotate: show an empty state and skip the toolbar, which
// would otherwise dereference a non-existent slide's annotation state.
if (_slideCount == 0) {
return Scaffold(
appBar: AppBar(
title: Text(_fileName, style: const TextStyle(fontSize: 16)),
),
body: const Center(child: Text('No slides to display')),
);
}
return Scaffold(
appBar: AppBar(
title: Text(_fileName, style: const TextStyle(fontSize: 16)),
actions: [
if (_extractedText.isNotEmpty)
IconButton(
icon: Icon(
_showTextPanel
? Icons.text_snippet
: Icons.text_snippet_outlined,
),
tooltip: 'Toggle extracted text',
onPressed: () => setState(() => _showTextPanel = !_showTextPanel),
),
IconButton(
icon: const Icon(Icons.picture_as_pdf),
tooltip: 'Export as PDF',
onPressed: _exportPdf,
),
],
),
body: Column(
children: [
AnnotationToolbar(
currentTool: _currentTool,
currentColor: _currentColor,
currentStrokeWidth: _currentStrokeWidth,
filled: _filled,
pressureCurveType: _pressureCurveType,
stabilizationLevel: _stabilizationLevel,
canUndo: _currentUndoManager.canUndo,
canRedo: _currentUndoManager.canRedo,
onToolChanged: (tool) => setState(() => _currentTool = tool),
onColorChanged: (color) => setState(() => _currentColor = color),
onStrokeWidthChanged: (w) =>
setState(() => _currentStrokeWidth = w),
onFilledChanged: (f) => setState(() => _filled = f),
onPressureCurveChanged: (v) =>
setState(() => _pressureCurveType = v),
onStabilizationChanged: (v) =>
setState(() => _stabilizationLevel = v),
onUndo: _undo,
onRedo: _redo,
),
Expanded(
child: Row(
children: [
Expanded(child: _buildSlideViewer()),
if (_showTextPanel) _buildTextPanel(),
],
),
),
_buildPageIndicator(),
],
),
);
}
Widget _buildSlideViewer() {
if (_slideCount == 0) {
return const Center(child: Text('No slides to display'));
}
return Listener(
onPointerDown: (_) => setState(() => _isDrawing = true),
onPointerUp: (_) => setState(() => _isDrawing = false),
child: PageView.builder(
controller: _pageController,
physics: _isDrawing ? const NeverScrollableScrollPhysics() : null,
itemCount: _slideCount,
onPageChanged: (page) => setState(() => _currentPage = page),
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.all(8),
child: Stack(
children: [
// Slide image (background)
Positioned.fill(
child: Image.file(
File(widget.slideImagePaths[index]),
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) => Container(
color: Colors.grey.shade200,
child: Center(
child: Text(
'Slide ${index + 1}',
style: TextStyle(
fontSize: 24,
color: Colors.grey.shade500,
),
),
),
),
),
),
// Ink annotation overlay (foreground)
Positioned.fill(
child: InkCanvas(
strokes: _annotations[index]?.strokes ?? [],
onStrokeComplete: _onStrokeComplete,
onErase: _onErase,
tool: _currentTool,
color: _currentColor,
strokeWidth: _currentStrokeWidth,
pressureCurve: _pressureCurve,
stabilizationLevel: _stabilizationLevel,
filled: _filled,
),
),
],
),
);
},
),
);
}
Widget _buildPageIndicator() {
if (_slideCount == 0) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
color: Theme.of(context).colorScheme.surface,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Previous button — always present for both modes.
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: _currentPage > 0
? () => _pageController.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
)
: null,
),
// Dot row for small decks; compact text counter for large decks.
if (_slideCount <= 12)
...List.generate(_slideCount, (i) {
final isActive = i == _currentPage;
return GestureDetector(
onTap: () => _pageController.animateToPage(
i,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
),
child: Container(
width: isActive ? 12 : 8,
height: isActive ? 12 : 8,
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isActive
? Theme.of(context).colorScheme.primary
: Colors.grey.shade400,
),
),
);
})
else
Text(
'${_currentPage + 1} / $_slideCount',
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
// Next button — always present for both modes.
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: _currentPage < _slideCount - 1
? () => _pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
)
: null,
),
const Spacer(),
// Slide counter is always shown at the trailing end for dot mode;
// the compact text above already serves this role for large decks.
if (_slideCount <= 12)
Text(
'${_currentPage + 1} / $_slideCount',
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
],
),
);
}
Widget _buildTextPanel() {
return SizedBox(
width: 280,
child: Card(
margin: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
),
child: Row(
children: [
const Icon(Icons.text_fields, size: 18),
const SizedBox(width: 8),
Text(
'Extracted Text',
style: Theme.of(context).textTheme.titleSmall,
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: () => setState(() => _showTextPanel = false),
),
],
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: SelectableText(
_extractedText,
style: Theme.of(context).textTheme.bodySmall,
),
),
),
],
),
),
);
}
// -- Dialogs --
void _undo() {
setState(() => _currentUndoManager.undo());
}
void _redo() {
setState(() => _currentUndoManager.redo());
}
}

View File

@@ -0,0 +1,286 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/note.dart';
import '../providers/search_provider.dart';
import 'note_editor_screen.dart';
import 'pdf_annotator_screen.dart';
class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key});
@override
ConsumerState<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends ConsumerState<SearchScreen> {
final TextEditingController _controller = TextEditingController();
Timer? _debounce;
@override
void initState() {
super.initState();
_controller.addListener(() => setState(() {}));
}
@override
void dispose() {
_debounce?.cancel();
_controller.dispose();
super.dispose();
}
void _onQueryChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () {
ref.read(searchQueryProvider.notifier).state = value.trim();
});
}
@override
Widget build(BuildContext context) {
final results = ref.watch(searchResultsProvider);
return Scaffold(
appBar: AppBar(
title: TextField(
controller: _controller,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search notes and documents...',
border: InputBorder.none,
hintStyle: TextStyle(color: Colors.grey),
),
onChanged: _onQueryChanged,
onSubmitted: (value) {
_debounce?.cancel();
ref.read(searchQueryProvider.notifier).state = value.trim();
},
),
actions: [
if (_controller.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_controller.clear();
ref.read(searchQueryProvider.notifier).state = '';
},
),
],
),
body: results.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Search error: $e')),
data: (hits) {
final query = ref.watch(searchQueryProvider);
if (query.isEmpty) {
return const Center(
child: Text(
'Type to search your notes and documents',
style: TextStyle(color: Colors.grey),
),
);
}
if (hits.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.search_off,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No results for "$query"',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
}
final noteHits = hits.whereType<NoteSearchHit>().toList();
final docHits = hits.whereType<DocumentSearchHit>().toList();
return ListView(
children: [
if (noteHits.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
'Notes',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
),
...noteHits.map(
(hit) => _NoteSearchResultTile(note: hit.note, query: query),
),
],
if (docHits.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
'Documents',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
),
...docHits.map(
(hit) => _DocumentSearchResultTile(
documentId: hit.documentId,
filename: hit.filename,
filePath: hit.filePath,
pageNumber: hit.pageNumber,
snippet: hit.snippet,
query: query,
),
),
],
],
);
},
),
);
}
}
class _NoteSearchResultTile extends StatelessWidget {
final Note note;
final String query;
const _NoteSearchResultTile({required this.note, required this.query});
@override
Widget build(BuildContext context) {
final d = note.updatedAt;
final dateStr =
'${d.month}/${d.day}/${d.year} ${d.hour}:${d.minute.toString().padLeft(2, '0')}';
return ListTile(
leading: const Icon(Icons.edit_note),
title: _HighlightedText(text: note.title, query: query),
subtitle: Text(
'${note.strokes.length} stroke${note.strokes.length == 1 ? '' : 's'} · $dateStr',
style: Theme.of(context).textTheme.bodySmall,
),
onTap: () {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => NoteEditorScreen(note: note)));
},
);
}
}
class _DocumentSearchResultTile extends StatelessWidget {
final String documentId;
final String filename;
final String filePath;
final int pageNumber;
final String snippet;
final String query;
const _DocumentSearchResultTile({
required this.documentId,
required this.filename,
required this.filePath,
required this.pageNumber,
required this.snippet,
required this.query,
});
@override
Widget build(BuildContext context) {
return ListTile(
leading: const Icon(Icons.picture_as_pdf, color: Colors.red),
title: _HighlightedText(text: filename, query: query),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Page ${pageNumber + 1}',
style: Theme.of(context).textTheme.bodySmall,
),
if (snippet.isNotEmpty)
_HighlightedText(text: snippet, query: query, maxLines: 2),
],
),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
PdfAnnotatorScreen(filePath: filePath, initialPage: pageNumber),
),
);
},
);
}
}
/// Highlights matching portions of [text] that match [query].
class _HighlightedText extends StatelessWidget {
final String text;
final String query;
final int maxLines;
const _HighlightedText({
required this.text,
required this.query,
this.maxLines = 1,
});
@override
Widget build(BuildContext context) {
if (query.isEmpty || text.isEmpty) {
return Text(text, maxLines: maxLines, overflow: TextOverflow.ellipsis);
}
final lowerText = text.toLowerCase();
final lowerQuery = query.toLowerCase();
final spans = <TextSpan>[];
int start = 0;
while (true) {
final index = lowerText.indexOf(lowerQuery, start);
if (index < 0) {
if (start < text.length) {
spans.add(TextSpan(text: text.substring(start)));
}
break;
}
if (index > start) {
spans.add(TextSpan(text: text.substring(start, index)));
}
spans.add(
TextSpan(
text: text.substring(index, index + query.length),
style: TextStyle(
fontWeight: FontWeight.bold,
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
),
),
);
start = index + query.length;
}
return RichText(
maxLines: maxLines,
overflow: TextOverflow.ellipsis,
text: TextSpan(
style: DefaultTextStyle.of(context).style,
children: spans,
),
);
}
}

View File

@@ -0,0 +1,344 @@
import 'package:flutter/material.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../providers/settings_provider.dart';
import '../utils/stroke_stabilizer.dart';
/// Material 3 settings screen for BadNote.
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
void _showColorPicker(
BuildContext context,
Color current,
ValueChanged<Color> onPicked,
) {
Color pickerColor = current;
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Pick a color'),
content: SingleChildScrollView(
child: ColorPicker(
pickerColor: pickerColor,
onColorChanged: (color) => pickerColor = color,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
onPicked(pickerColor);
Navigator.of(context).pop();
},
child: const Text('OK'),
),
],
);
},
);
}
void _confirmClearData(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Clear all local settings?'),
content: const Text(
'This will reset pen defaults and appearance settings. '
'Notes and documents are not affected.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
ref.read(settingsProvider).clearAllData();
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Settings reset to defaults')),
);
},
child: const Text('Clear'),
),
],
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
children: [
_SectionHeader(title: 'Defaults', icon: Icons.tune),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Default Tool',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
DropdownButtonFormField<PenTool>(
initialValue: settings.defaultTool,
decoration: const InputDecoration(
border: OutlineInputBorder(),
isDense: true,
),
items: PenTool.values.map((tool) {
return DropdownMenuItem(
value: tool,
child: Text(tool.name),
);
}).toList(),
onChanged: (tool) {
if (tool != null) settings.setDefaultTool(tool);
},
),
const SizedBox(height: 16),
const Text(
'Default Color',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
Row(
children: [
MouseRegion(
cursor: SystemMouseCursors.click,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => _showColorPicker(
context,
settings.defaultColor,
settings.setDefaultColor,
),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: settings.defaultColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colorScheme.outline),
),
),
),
),
const SizedBox(width: 12),
Text(
'#${settings.defaultColor.toARGB32().toRadixString(16).padLeft(8, '0').toUpperCase()}',
style: const TextStyle(fontFamily: 'monospace'),
),
],
),
const SizedBox(height: 16),
const Text(
'Default Stroke Width',
style: TextStyle(fontWeight: FontWeight.w500),
),
Slider(
value: settings.defaultStrokeWidth,
min: 1.0,
max: 20.0,
divisions: 19,
label: settings.defaultStrokeWidth.toStringAsFixed(1),
onChanged: settings.setDefaultStrokeWidth,
),
const SizedBox(height: 16),
const Text(
'Pressure Curve',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
DropdownButtonFormField<PressureCurveType>(
initialValue: settings.defaultPressureCurve,
decoration: const InputDecoration(
border: OutlineInputBorder(),
isDense: true,
),
items: PressureCurveType.values.map((curve) {
return DropdownMenuItem(
value: curve,
child: Text(curve.name),
);
}).toList(),
onChanged: (curve) {
if (curve != null) {
settings.setDefaultPressureCurve(curve);
}
},
),
const SizedBox(height: 16),
const Text(
'Stabilization',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
DropdownButtonFormField<StabilizationLevel>(
initialValue: settings.defaultStabilization,
decoration: const InputDecoration(
border: OutlineInputBorder(),
isDense: true,
),
items: StabilizationLevel.values.map((level) {
return DropdownMenuItem(
value: level,
child: Text(level.name),
);
}).toList(),
onChanged: (level) {
if (level != null) settings.setDefaultStabilization(level);
},
),
],
),
),
const Divider(),
_SectionHeader(title: 'Appearance', icon: Icons.palette),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Theme Mode',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(
value: ThemeMode.system,
label: Text('System'),
icon: Icon(Icons.brightness_auto),
),
ButtonSegment(
value: ThemeMode.light,
label: Text('Light'),
icon: Icon(Icons.light_mode),
),
ButtonSegment(
value: ThemeMode.dark,
label: Text('Dark'),
icon: Icon(Icons.dark_mode),
),
],
selected: {settings.themeMode},
onSelectionChanged: (modes) {
settings.setThemeMode(modes.first);
},
),
const SizedBox(height: 16),
const Text(
'Color Scheme Seed',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
Row(
children: [
MouseRegion(
cursor: SystemMouseCursors.click,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => _showColorPicker(
context,
settings.colorSchemeSeed,
settings.setColorSchemeSeed,
),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: settings.colorSchemeSeed,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colorScheme.outline),
),
),
),
),
const SizedBox(width: 12),
const Text('Seed color for Material 3 theme'),
],
),
],
),
),
const Divider(),
_SectionHeader(title: 'About', icon: Icons.info),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'BadNote v0.1.0',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
Text(
'Local-first Surface Pen note-taking with PDF/PPT annotation. '
'OCR and search run entirely on your device.',
style: TextStyle(
fontSize: 13,
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () => _confirmClearData(context, ref),
icon: const Icon(Icons.delete_forever, color: Colors.red),
label: const Text(
'Clear All Local Settings',
style: TextStyle(color: Colors.red),
),
),
],
),
),
const SizedBox(height: 32),
],
),
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
final IconData icon;
const _SectionHeader({required this.title, required this.icon});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Row(
children: [
Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
Text(
title,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
],
),
);
}
}

View File

@@ -0,0 +1,469 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import '../models/ink_stroke.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../services/database_service.dart';
import '../services/undo_manager.dart';
import '../utils/stroke_stabilizer.dart';
import '../widgets/annotation_toolbar.dart';
import '../widgets/ink_canvas.dart';
/// Split-view derivation mode: left pane = reference PDF, right pane = infinite
/// scratchpad for formula derivation. Scratchpad strokes are persisted per
/// document via [DatabaseService.saveScratchpad] / [DatabaseService.loadScratchpad].
class SplitViewScreen extends StatefulWidget {
final String filePath;
final String documentId;
const SplitViewScreen({
super.key,
required this.filePath,
required this.documentId,
});
@override
State<SplitViewScreen> createState() => _SplitViewState();
}
class _SplitViewState extends State<SplitViewScreen> {
// -- PDF (left pane) --
final PdfViewerController _pdfController = PdfViewerController();
int _currentPage = 0;
int _pageCount = 0;
String _fileName = '';
// -- Split divider --
double _leftPaneFraction = 0.5;
bool _isDraggingDivider = false;
// -- Scratchpad (right pane) --
final UndoManager _undoManager = UndoManager();
List<InkStroke> _strokes = [];
double _canvasWidth = 4000;
double _canvasHeight = 4000;
// -- Tool state --
PenTool _currentTool = PenTool.pen;
Color _currentColor = Colors.black;
double _currentStrokeWidth = 2.0;
bool _filled = false;
PressureCurveType _pressureCurveType = PressureCurveType.linear;
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
// -- Auto-save debounce --
Timer? _saveTimer;
bool _dirty = false;
// -- Page link markers (optional feature) --
final List<_PageLink> _pageLinks = [];
static const double _edgeThreshold = 200.0;
static const double _expandAmount = 1000.0;
@override
void initState() {
super.initState();
_loadScratchpad();
}
@override
void dispose() {
_saveTimer?.cancel();
_saveImmediate();
_pdfController.dispose();
super.dispose();
}
// -- Persistence --
Future<void> _loadScratchpad() async {
final db = await DatabaseService.getInstance();
final strokes = await db.loadScratchpad(widget.documentId);
if (mounted) {
setState(() {
_strokes = strokes;
for (final s in strokes) {
_undoManager.addStroke(s);
}
});
}
}
void _scheduleSave() {
_dirty = true;
_saveTimer?.cancel();
_saveTimer = Timer(const Duration(seconds: 3), _saveImmediate);
}
Future<void> _saveImmediate() async {
if (!_dirty) return;
_dirty = false;
final db = await DatabaseService.getInstance();
final json = jsonEncode(_strokes.map((s) => s.toJson()).toList());
await db.saveScratchpad(widget.documentId, json);
}
// -- Scratchpad stroke callbacks --
void _onStrokeComplete(InkStroke stroke) {
setState(() {
_strokes.add(stroke);
_undoManager.addStroke(stroke);
_checkCanvasExpansion(stroke);
});
_scheduleSave();
}
void _onErase(String strokeId, List<InkStroke> replacements) {
setState(() {
final original = _strokes.where((s) => s.id == strokeId).firstOrNull;
if (original != null) {
_undoManager.removeStroke(original, replacements: replacements);
_strokes = List.from(_undoManager.currentStrokes);
}
});
_scheduleSave();
}
void _undo() {
setState(() {
_undoManager.undo();
_strokes = List.from(_undoManager.currentStrokes);
});
_scheduleSave();
}
void _redo() {
setState(() {
_undoManager.redo();
_strokes = List.from(_undoManager.currentStrokes);
});
_scheduleSave();
}
// -- Auto-expand canvas --
void _checkCanvasExpansion(InkStroke stroke) {
double maxRight = 0;
double maxBottom = 0;
for (final p in stroke.points) {
if (p.x > maxRight) maxRight = p.x;
if (p.y > maxBottom) maxBottom = p.y;
}
bool expanded = false;
if (maxRight > _canvasWidth - _edgeThreshold) {
_canvasWidth += _expandAmount;
expanded = true;
}
if (maxBottom > _canvasHeight - _edgeThreshold) {
_canvasHeight += _expandAmount;
expanded = true;
}
if (expanded) setState(() {});
}
// -- Divider drag --
void _onDividerDragStart(DragStartDetails details) {
setState(() => _isDraggingDivider = true);
}
void _onDividerDragUpdate(
DragUpdateDetails details,
BoxConstraints constraints,
) {
final renderWidth = constraints.maxWidth;
if (renderWidth <= 0) return;
final delta = details.delta.dx / renderWidth;
setState(() {
_leftPaneFraction = (_leftPaneFraction + delta).clamp(0.2, 0.8);
});
}
void _onDividerDragEnd(DragEndDetails details) {
setState(() => _isDraggingDivider = false);
}
// -- PDF page navigation --
void _prevPage() {
if (_currentPage > 0) {
_pdfController.previousPage();
}
}
void _nextPage() {
if (_currentPage < _pageCount - 1) {
_pdfController.nextPage();
}
}
// -- Page link creation (long-press on left pane) --
void _onPdfLongPress(int pageNumber) {
// Place a page link marker at the current scratchpad viewport center.
// We approximate the viewport center as (0, 0) since InteractiveViewer
// manages its own transform — the user can reposition by panning.
setState(() {
_pageLinks.add(
_PageLink(
pageNumber: pageNumber,
position: const Offset(100, 100), // default top-left area
),
);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Page link marker added for page $pageNumber')),
);
}
void _onPageLinkTap(_PageLink link) {
_pdfController.jumpToPage(link.pageNumber);
setState(() {
_currentPage = link.pageNumber - 1;
});
}
void _deletePageLink(_PageLink link) {
setState(() {
_pageLinks.remove(link);
});
}
// -- Build --
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
_fileName.isEmpty ? 'Split View' : _fileName,
style: const TextStyle(fontSize: 16),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
_saveImmediate();
Navigator.of(context).pop();
},
),
actions: [
// Left pane page navigation
IconButton(
icon: const Icon(Icons.navigate_before),
tooltip: 'Previous page (PDF)',
onPressed: _currentPage > 0 ? _prevPage : null,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Center(
child: Text(
'${_currentPage + 1} / $_pageCount',
style: const TextStyle(fontSize: 13),
),
),
),
IconButton(
icon: const Icon(Icons.navigate_next),
tooltip: 'Next page (PDF)',
onPressed: _currentPage < _pageCount - 1 ? _nextPage : null,
),
const SizedBox(width: 8),
// Canvas info
Tooltip(
message:
'Scratchpad size: ${_canvasWidth.round()} x ${_canvasHeight.round()}',
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Center(
child: Text(
'${_canvasWidth.round()}x${_canvasHeight.round()}',
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
),
),
),
],
),
body: Column(
children: [
// Label clarifying that the toolbar controls the scratchpad pane.
Padding(
padding: const EdgeInsets.only(left: 12, top: 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Scratchpad tools',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
),
// Toolbar (applies to scratchpad only)
AnnotationToolbar(
currentTool: _currentTool,
currentColor: _currentColor,
currentStrokeWidth: _currentStrokeWidth,
filled: _filled,
pressureCurveType: _pressureCurveType,
stabilizationLevel: _stabilizationLevel,
canUndo: _undoManager.canUndo,
canRedo: _undoManager.canRedo,
onToolChanged: (tool) => setState(() => _currentTool = tool),
onColorChanged: (color) => setState(() => _currentColor = color),
onStrokeWidthChanged: (w) =>
setState(() => _currentStrokeWidth = w),
onFilledChanged: (f) => setState(() => _filled = f),
onPressureCurveChanged: (v) =>
setState(() => _pressureCurveType = v),
onStabilizationChanged: (v) =>
setState(() => _stabilizationLevel = v),
onUndo: _undo,
onRedo: _redo,
),
// Split view body
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final totalWidth = constraints.maxWidth;
final leftWidth = totalWidth * _leftPaneFraction;
final rightWidth =
totalWidth - leftWidth - 12; // 12px divider hit area
return Row(
children: [
// Left pane: PDF reference (read-only)
SizedBox(width: leftWidth, child: _buildPdfPane()),
// Draggable divider: 12px hit area, 4px visual strip.
GestureDetector(
onHorizontalDragStart: _onDividerDragStart,
onHorizontalDragUpdate: (d) =>
_onDividerDragUpdate(d, constraints),
onHorizontalDragEnd: _onDividerDragEnd,
child: MouseRegion(
cursor: SystemMouseCursors.resizeColumn,
child: SizedBox(
width: 12,
child: Center(
child: Container(
width: 4,
color: _isDraggingDivider
? Theme.of(context).colorScheme.primary
: Theme.of(context).dividerColor,
),
),
),
),
),
// Right pane: Infinite scratchpad
SizedBox(width: rightWidth, child: _buildScratchpadPane()),
],
);
},
),
),
],
),
);
}
Widget _buildPdfPane() {
return Stack(
children: [
GestureDetector(
onLongPress: () {
// Long-press on PDF to create page link marker
_onPdfLongPress(_currentPage + 1);
},
child: SfPdfViewer.file(
File(widget.filePath),
controller: _pdfController,
canShowScrollHead: true,
canShowScrollStatus: true,
onPageChanged: (PdfPageChangedDetails details) {
setState(() {
_currentPage = details.newPageNumber - 1;
});
},
onDocumentLoaded: (PdfDocumentLoadedDetails details) {
setState(() {
_pageCount = details.document.pages.count;
_fileName = widget.filePath.split(Platform.pathSeparator).last;
});
},
),
),
// Page link markers overlay (on PDF pane, showing linked pages)
if (_pageLinks.isNotEmpty)
Positioned(bottom: 8, left: 8, child: _buildPageLinkChips()),
],
);
}
Widget _buildPageLinkChips() {
return Wrap(
spacing: 4,
runSpacing: 4,
children: _pageLinks.map((link) {
return GestureDetector(
onTap: () => _onPageLinkTap(link),
onLongPress: () => _deletePageLink(link),
child: Chip(
avatar: const Icon(Icons.link, size: 14, color: Colors.white),
label: Text(
'p${link.pageNumber}',
style: const TextStyle(fontSize: 11, color: Colors.white),
),
backgroundColor: Colors.blue.shade600,
padding: EdgeInsets.zero,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
);
}).toList(),
);
}
Widget _buildScratchpadPane() {
return Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: InteractiveViewer(
constrained: false,
minScale: 0.25,
maxScale: 8.0,
boundaryMargin: const EdgeInsets.all(double.infinity),
child: SizedBox(
width: _canvasWidth,
height: _canvasHeight,
child: InkCanvas(
strokes: _strokes,
onStrokeComplete: _onStrokeComplete,
onErase: _onErase,
tool: _currentTool,
color: _currentColor,
strokeWidth: _currentStrokeWidth,
pressureCurve: PressureCurve(type: _pressureCurveType),
stabilizationLevel: _stabilizationLevel,
filled: _filled,
interactionMode: InteractionMode.draw,
),
),
),
);
}
}
/// A marker linking a scratchpad position to a specific PDF page.
class _PageLink {
final int pageNumber;
final Offset position;
const _PageLink({required this.pageNumber, required this.position});
}

View File

@@ -0,0 +1,20 @@
import 'package:image_picker/image_picker.dart';
/// Thin wrapper around image_picker for camera capture and gallery selection.
class CameraService {
final ImagePicker _picker = ImagePicker();
/// Capture a photo using the device camera.
/// Returns the file path, or null if the user cancelled.
Future<String?> capturePhoto() async {
final XFile? image = await _picker.pickImage(source: ImageSource.camera);
return image?.path;
}
/// Pick an image from the device gallery.
/// Returns the file path, or null if the user cancelled.
Future<String?> pickFromGallery() async {
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
return image?.path;
}
}

View File

@@ -0,0 +1,841 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:uuid/uuid.dart';
import '../models/bookmark.dart';
import '../models/document.dart' as doc;
import '../models/ink_point.dart';
import '../models/ink_stroke.dart';
import '../models/note.dart';
import '../models/pen_tool.dart';
import '../models/pointer_device_kind.dart';
class DatabaseService {
static DatabaseService? _instance;
late Database _database;
DatabaseService._();
static Future<DatabaseService> getInstance() async {
if (_instance != null) return _instance!;
final service = DatabaseService._();
await service._initialize();
_instance = service;
return service;
}
Database get database => _database;
Future<void> _initialize() async {
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
}
final dir = await getApplicationDocumentsDirectory();
final dbPath = p.join(dir.path, 'badnote.db');
_database = await openDatabase(
dbPath,
version: 5,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
}
Future<void> _onCreate(Database db, int version) async {
// Core tables (original v1)
await db.execute('''
CREATE TABLE notes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]'
)
''');
await db.execute('''
CREATE TABLE strokes (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL,
tool TEXT NOT NULL,
color INTEGER NOT NULL,
stroke_width REAL NOT NULL,
created_at TEXT NOT NULL,
points TEXT NOT NULL,
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
)
''');
await db.execute('CREATE INDEX idx_strokes_note_id ON strokes(note_id)');
await _createFtsTable(db);
// Documents & annotations (originally v2, now part of fresh install)
await db.execute('''
CREATE TABLE documents (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
doc_type TEXT NOT NULL,
file_path TEXT NOT NULL,
page_count INTEGER NOT NULL DEFAULT 0,
rotation INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
await db.execute('''
CREATE TABLE annotations (
id TEXT PRIMARY KEY,
uuid TEXT NOT NULL,
document_id TEXT NOT NULL,
page_number INTEGER NOT NULL,
annotation_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
)
''');
await db.execute(
'CREATE INDEX idx_annotations_doc_page ON annotations(document_id, page_number)',
);
await db.execute('''
CREATE TABLE bookmarks (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
page_number INTEGER NOT NULL,
label TEXT NOT NULL DEFAULT '',
color INTEGER NOT NULL DEFAULT 4283215696,
created_at TEXT NOT NULL,
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
)
''');
await db.execute(
'CREATE INDEX idx_bookmarks_doc ON bookmarks(document_id)',
);
await db.execute('''
CREATE TABLE ocr_results (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
page_number INTEGER NOT NULL,
ocr_text TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
)
''');
// Document FTS (v3)
await db.execute('''
CREATE VIRTUAL TABLE document_fts USING fts5(
document_id, page_number, content, tokenize='porter unicode61'
)
''');
// Scratchpads (v5)
await db.execute('''
CREATE TABLE scratchpads (
id TEXT PRIMARY KEY,
document_id TEXT UNIQUE NOT NULL,
strokes_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
)
''');
await db.execute(
'CREATE INDEX idx_scratchpads_doc ON scratchpads(document_id)',
);
}
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);
}
Future<void> _migrateV2toV3(Database db) async {
// Wrap the whole migration in a transaction: a failure mid-migration
// (after DROP TABLE annotations) would otherwise destroy data.
await db.transaction((txn) async {
// Add uuid column to annotations
await txn.execute('ALTER TABLE annotations ADD COLUMN uuid TEXT');
// Generate UUIDs for existing rows
await txn.rawUpdate(
"UPDATE annotations SET uuid = hex(randomblob(16)) WHERE uuid IS NULL",
);
// Recreate annotations table with UUID primary key
await txn.execute('''
CREATE TABLE annotations_new (
id TEXT PRIMARY KEY,
uuid TEXT NOT NULL,
document_id TEXT NOT NULL,
page_number INTEGER NOT NULL,
annotation_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
)
''');
await txn.rawInsert('''
INSERT INTO annotations_new (id, uuid, document_id, page_number, annotation_json, created_at, updated_at)
SELECT id, uuid, document_id, page_number, annotation_json, created_at, updated_at FROM annotations
''');
await txn.execute('DROP TABLE annotations');
await txn.execute('ALTER TABLE annotations_new RENAME TO annotations');
await txn.execute(
'CREATE INDEX idx_annotations_doc_page ON annotations(document_id, page_number)',
);
// Create document FTS table
await txn.execute('''
CREATE VIRTUAL TABLE document_fts USING fts5(
document_id, page_number, content, tokenize='porter unicode61'
)
''');
// Add rotation column to documents
await txn.execute(
'ALTER TABLE documents ADD COLUMN rotation INTEGER NOT NULL DEFAULT 0',
);
});
}
Future<void> _migrateV4toV5(Database db) async {
// Wrap in a transaction so a partial failure does not leave the schema
// in an inconsistent state.
await db.transaction((txn) async {
// Create scratchpads table
await txn.execute('''
CREATE TABLE scratchpads (
id TEXT PRIMARY KEY,
document_id TEXT UNIQUE NOT NULL,
strokes_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
)
''');
await txn.execute(
'CREATE INDEX idx_scratchpads_doc ON scratchpads(document_id)',
);
});
}
// ── Notes CRUD ──────────────────────────────────────────────────────
Future<List<Note>> getAllNotes() async {
final noteRows = await _database.query('notes', orderBy: 'updated_at DESC');
final notes = <Note>[];
for (final row in noteRows) {
notes.add(await _noteFromRow(row));
}
return notes;
}
Future<Note?> getNoteById(String id) async {
final rows = await _database.query(
'notes',
where: 'id = ?',
whereArgs: [id],
);
if (rows.isEmpty) return null;
return _noteFromRow(rows.first);
}
Future<void> insertNote(Note note) async {
// Atomic: the note row, its strokes, and the FTS index must all commit
// together or not at all.
await _database.transaction((txn) async {
await txn.insert('notes', {
'id': note.id,
'title': note.title,
'created_at': note.createdAt.toIso8601String(),
'updated_at': note.updatedAt.toIso8601String(),
'tags': jsonEncode(note.tags),
});
for (final stroke in note.strokes) {
await _insertStroke(txn, note.id, stroke);
}
await _extractAndIndexNoteContent(txn, note);
});
}
Future<void> updateNote(Note note) async {
// Atomic: this deletes all strokes then re-inserts them and rebuilds the
// FTS entry. An interruption mid-way would permanently lose strokes, so
// the whole sequence must run inside one transaction.
await _database.transaction((txn) async {
await txn.update(
'notes',
{
'title': note.title,
'updated_at': note.updatedAt.toIso8601String(),
'tags': jsonEncode(note.tags),
},
where: 'id = ?',
whereArgs: [note.id],
);
// Replace all strokes for this note
await txn.delete('strokes', where: 'note_id = ?', whereArgs: [note.id]);
for (final stroke in note.strokes) {
await _insertStroke(txn, note.id, stroke);
}
await removeFromFts(txn, note.id);
await _extractAndIndexNoteContent(txn, note);
});
}
Future<void> deleteNote(String id) async {
await _database.transaction((txn) async {
await txn.delete('strokes', where: 'note_id = ?', whereArgs: [id]);
await txn.delete('notes', where: 'id = ?', whereArgs: [id]);
await removeFromFts(txn, id);
});
}
// ── Strokes ─────────────────────────────────────────────────────────
Future<void> _insertStroke(
DatabaseExecutor db,
String noteId,
InkStroke stroke,
) async {
await db.insert('strokes', {
'id': stroke.id,
'note_id': noteId,
'tool': stroke.tool.name,
'color': stroke.color,
'stroke_width': stroke.strokeWidth,
'created_at': stroke.createdAt.toIso8601String(),
'points': jsonEncode(stroke.points.map(_pointToJson).toList()),
});
}
Future<List<InkStroke>> _getStrokesForNote(String noteId) async {
final rows = await _database.query(
'strokes',
where: 'note_id = ?',
whereArgs: [noteId],
orderBy: 'created_at ASC',
);
return rows.map(_strokeFromRow).toList();
}
// ── Serialization helpers ───────────────────────────────────────────
Map<String, dynamic> _pointToJson(InkPoint p) => {
'x': p.x,
'y': p.y,
'pressure': p.pressure,
'tilt': p.tilt,
'timestamp': p.timestamp,
'pointerDeviceKind': p.pointerDeviceKind.name,
};
InkPoint _pointFromJson(Map<String, dynamic> json) => InkPoint(
x: (json['x'] as num).toDouble(),
y: (json['y'] as num).toDouble(),
pressure: (json['pressure'] as num?)?.toDouble() ?? 0.5,
tilt: (json['tilt'] as num?)?.toDouble() ?? 0.0,
timestamp: json['timestamp'] as int,
pointerDeviceKind: _parseDeviceKind(json['pointerDeviceKind'] as String?),
);
InputDeviceKind _parseDeviceKind(String? value) {
if (value == null) return InputDeviceKind.unknown;
return InputDeviceKind.values.asNameMap()[value] ?? InputDeviceKind.unknown;
}
InkStroke _strokeFromRow(Map<String, dynamic> row) {
final pointsJson = jsonDecode(row['points'] as String) as List;
return InkStroke(
id: row['id'] as String,
points: pointsJson
.map((p) => _pointFromJson(p as Map<String, dynamic>))
.toList(),
tool: _parsePenTool(row['tool'] as String),
color: row['color'] as int,
strokeWidth: (row['stroke_width'] as num).toDouble(),
createdAt: DateTime.parse(row['created_at'] as String),
);
}
PenTool _parsePenTool(String value) {
return PenTool.values.asNameMap()[value] ?? PenTool.pen;
}
Future<Note> _noteFromRow(Map<String, dynamic> row) async {
final tagsJson = jsonDecode(row['tags'] as String) as List;
final strokes = await _getStrokesForNote(row['id'] as String);
return Note(
id: row['id'] as String,
title: row['title'] as String,
strokes: strokes,
createdAt: DateTime.parse(row['created_at'] as String),
updatedAt: DateTime.parse(row['updated_at'] as String),
tags: tagsJson.cast<String>(),
);
}
// ── Full-Text Search (FTS5) ────────────────────────────────────────
Future<void> _createFtsTable(Database db) async {
await db.execute('''
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
note_id, title, content, tokenize='porter unicode61'
)
''');
}
/// Index a note's text content for full-text search.
/// [content] should include any typed text, OCR text, etc.
Future<void> indexNoteContent(
DatabaseExecutor db,
String noteId,
String title,
String content,
) async {
await db.insert('notes_fts', {
'note_id': noteId,
'title': title,
'content': content,
});
}
/// Extract text content from a note's strokes and index it for FTS.
/// Concatenates the title with any textContent from strokes.
Future<void> _extractAndIndexNoteContent(
DatabaseExecutor db,
Note note,
) async {
final textParts = <String>[note.title];
for (final stroke in note.strokes) {
if (stroke.textContent != null && stroke.textContent!.isNotEmpty) {
textParts.add(stroke.textContent!);
}
}
final content = textParts.join(' ');
await indexNoteContent(db, note.id, note.title, content);
}
/// Append OCR text to an existing note's FTS entry.
/// Reads current content, merges with new OCR text, and re-indexes.
Future<void> appendOcrToFts(String noteId, String ocrText) async {
if (ocrText.trim().isEmpty) return;
// Read-modify-write must be atomic: querying the current content, removing
// the old entry, and re-inserting the merged content all run inside one
// transaction so a concurrent writer cannot cause a lost update.
await _database.transaction((txn) async {
// Read current FTS content
final rows = await txn.query(
'notes_fts',
where: 'note_id = ?',
whereArgs: [noteId],
);
String existingContent = '';
String existingTitle = '';
if (rows.isNotEmpty) {
existingTitle = rows.first['title'] as String? ?? '';
existingContent = rows.first['content'] as String? ?? '';
}
// Merge: append OCR text to existing content
final mergedContent = existingContent.isEmpty
? ocrText
: '$existingContent $ocrText';
// Remove old entry and re-insert with merged content
await removeFromFts(txn, noteId);
await indexNoteContent(txn, noteId, existingTitle, mergedContent);
});
}
/// Full-text search across indexed notes.
Future<List<Note>> searchNotes(String query) async {
if (query.trim().isEmpty) return [];
// Sanitize query for FTS5: escape special chars and add prefix matching
final sanitized = query.replaceAll('"', '').replaceAll("'", '').trim();
if (sanitized.isEmpty) return [];
final ftsQuery = sanitized
.split(RegExp(r'\s+'))
.map((w) => '"$w"*')
.join(' ');
final rows = await _database.rawQuery(
'SELECT note_id FROM notes_fts WHERE notes_fts MATCH ? ORDER BY rank',
[ftsQuery],
);
final notes = <Note>[];
for (final row in rows) {
final noteId = row['note_id'] as String;
final note = await getNoteById(noteId);
if (note != null) {
notes.add(note);
}
}
return notes;
}
/// Remove a note from the FTS index.
Future<void> removeFromFts(DatabaseExecutor db, String noteId) async {
await db.delete('notes_fts', where: 'note_id = ?', whereArgs: [noteId]);
}
// ── Document FTS ────────────────────────────────────────────────────
/// Index a page's text content for document full-text search.
Future<void> indexDocumentContent(
String documentId,
int pageNumber,
String content,
) async {
// Remove existing entry for this page first
await _database.delete(
'document_fts',
where: 'document_id = ? AND page_number = ?',
whereArgs: [documentId, pageNumber],
);
await _database.insert('document_fts', {
'document_id': documentId,
'page_number': pageNumber.toString(),
'content': content,
});
}
/// Remove a page from the document FTS index.
Future<void> removeDocumentFromFts(
DatabaseExecutor db,
String documentId,
int pageNumber,
) async {
await db.delete(
'document_fts',
where: 'document_id = ? AND page_number = ?',
whereArgs: [documentId, pageNumber],
);
}
/// Full-text search across indexed document pages.
Future<List<Map<String, dynamic>>> searchDocuments(String query) async {
if (query.trim().isEmpty) return [];
final sanitized = query.replaceAll('"', '').replaceAll("'", '').trim();
if (sanitized.isEmpty) return [];
final ftsQuery = sanitized
.split(RegExp(r'\s+'))
.map((w) => '"$w"*')
.join(' ');
final rows = await _database.rawQuery(
'SELECT document_id, page_number, content FROM document_fts WHERE document_fts MATCH ? ORDER BY rank',
[ftsQuery],
);
return rows
.map(
(row) => {
'document_id': row['document_id'] as String,
'page_number': int.parse(row['page_number'] as String),
'content': row['content'] as String,
},
)
.toList();
}
// ── Documents CRUD ─────────────────────────────────────────────────
Future<void> insertDocument(doc.Document document) async {
await _database.insert('documents', {
'id': document.id,
'filename': document.filename,
'doc_type': document.docType,
'file_path': document.filePath,
'page_count': document.pageCount,
'rotation': document.rotation,
'created_at': document.createdAt.toIso8601String(),
'updated_at': document.updatedAt.toIso8601String(),
});
}
Future<doc.Document?> getDocument(String id) async {
final rows = await _database.query(
'documents',
where: 'id = ?',
whereArgs: [id],
);
if (rows.isEmpty) return null;
return _documentFromRow(rows.first);
}
Future<doc.Document?> getDocumentByPath(String filePath) async {
final rows = await _database.query(
'documents',
where: 'file_path = ?',
whereArgs: [filePath],
);
if (rows.isEmpty) return null;
return _documentFromRow(rows.first);
}
Future<List<doc.Document>> getAllDocuments() async {
final rows = await _database.query('documents', orderBy: 'updated_at DESC');
return rows.map(_documentFromRow).toList();
}
Future<void> deleteDocument(String id) async {
await _database.transaction((txn) async {
await txn.delete(
'annotations',
where: 'document_id = ?',
whereArgs: [id],
);
await txn.delete('bookmarks', where: 'document_id = ?', whereArgs: [id]);
await txn.delete(
'ocr_results',
where: 'document_id = ?',
whereArgs: [id],
);
await txn.delete(
'scratchpads',
where: 'document_id = ?',
whereArgs: [id],
);
await txn.delete('documents', where: 'id = ?', whereArgs: [id]);
});
}
doc.Document _documentFromRow(Map<String, dynamic> row) {
return doc.Document(
id: row['id'] as String,
filename: row['filename'] as String,
docType: row['doc_type'] as String,
filePath: row['file_path'] as String,
pageCount: row['page_count'] as int,
rotation: (row['rotation'] as int?) ?? 0,
createdAt: DateTime.parse(row['created_at'] as String),
updatedAt: DateTime.parse(row['updated_at'] as String),
);
}
// ── Annotations CRUD ───────────────────────────────────────────────
Future<void> saveAnnotations(
String documentId,
int pageNumber,
String annotationJson,
) async {
await _database.delete(
'annotations',
where: 'document_id = ? AND page_number = ?',
whereArgs: [documentId, pageNumber],
);
await _database.insert('annotations', {
'id': const Uuid().v4(),
'uuid': const Uuid().v4(),
'document_id': documentId,
'page_number': pageNumber,
'annotation_json': annotationJson,
'created_at': DateTime.now().toIso8601String(),
'updated_at': DateTime.now().toIso8601String(),
});
}
Future<String?> getAnnotations(String documentId, int pageNumber) async {
final rows = await _database.query(
'annotations',
where: 'document_id = ? AND page_number = ?',
whereArgs: [documentId, pageNumber],
);
if (rows.isEmpty) return null;
return rows.first['annotation_json'] as String;
}
Future<void> deleteDocumentAnnotations(String documentId) async {
await _database.delete(
'annotations',
where: 'document_id = ?',
whereArgs: [documentId],
);
}
// ── Annotation/Bookmark Remapping ──────────────────────────────────
/// After deleting a page at [deletedIndex], shift all annotations
/// with page_number > deletedIndex down by 1.
Future<void> remapAnnotationsAfterDelete(
String documentId,
int deletedIndex,
) async {
await _database.rawUpdate(
'UPDATE annotations SET page_number = page_number - 1 WHERE document_id = ? AND page_number > ?',
[documentId, deletedIndex],
);
}
/// After inserting a page at [insertedIndex], shift all annotations
/// with page_number >= insertedIndex up by 1.
Future<void> remapAnnotationsAfterInsert(
String documentId,
int insertedIndex,
) async {
await _database.rawUpdate(
'UPDATE annotations SET page_number = page_number + 1 WHERE document_id = ? AND page_number >= ?',
[documentId, insertedIndex],
);
}
/// After deleting a page at [deletedIndex], shift all bookmarks
/// with page_number > deletedIndex down by 1.
Future<void> remapBookmarksAfterDelete(
String documentId,
int deletedIndex,
) async {
await _database.rawUpdate(
'UPDATE bookmarks SET page_number = page_number - 1 WHERE document_id = ? AND page_number > ?',
[documentId, deletedIndex],
);
}
/// After inserting a page at [insertedIndex], shift all bookmarks
/// with page_number >= insertedIndex up by 1.
Future<void> remapBookmarksAfterInsert(
String documentId,
int insertedIndex,
) async {
await _database.rawUpdate(
'UPDATE bookmarks SET page_number = page_number + 1 WHERE document_id = ? AND page_number >= ?',
[documentId, insertedIndex],
);
}
/// Delete all annotations, bookmarks, and OCR data for a specific page.
Future<void> deletePageData(String documentId, int pageNumber) async {
await _database.transaction((txn) async {
await txn.delete(
'annotations',
where: 'document_id = ? AND page_number = ?',
whereArgs: [documentId, pageNumber],
);
await txn.delete(
'bookmarks',
where: 'document_id = ? AND page_number = ?',
whereArgs: [documentId, pageNumber],
);
await txn.delete(
'ocr_results',
where: 'document_id = ? AND page_number = ?',
whereArgs: [documentId, pageNumber],
);
await removeDocumentFromFts(txn, documentId, pageNumber);
});
}
/// Update the stored page count for a document.
Future<void> updateDocumentPageCount(
String documentId,
int newPageCount,
) async {
await _database.update(
'documents',
{
'page_count': newPageCount,
'updated_at': DateTime.now().toIso8601String(),
},
where: 'id = ?',
whereArgs: [documentId],
);
}
// ── Bookmarks CRUD ─────────────────────────────────────────────────
Future<void> insertBookmark(Bookmark bookmark) async {
await _database.insert('bookmarks', {
'id': bookmark.id,
'document_id': bookmark.documentId,
'page_number': bookmark.pageNumber,
'label': bookmark.label,
'color': bookmark.color,
'created_at': bookmark.createdAt.toIso8601String(),
});
}
Future<List<Bookmark>> getBookmarks(String documentId) async {
final rows = await _database.query(
'bookmarks',
where: 'document_id = ?',
whereArgs: [documentId],
orderBy: 'page_number ASC',
);
return rows.map(_bookmarkFromRow).toList();
}
Future<void> deleteBookmark(String id) async {
await _database.delete('bookmarks', where: 'id = ?', whereArgs: [id]);
}
Bookmark _bookmarkFromRow(Map<String, dynamic> row) {
return Bookmark(
id: row['id'] as String,
documentId: row['document_id'] as String,
pageNumber: row['page_number'] as int,
label: row['label'] as String,
color: row['color'] as int,
createdAt: DateTime.parse(row['created_at'] as String),
);
}
// ── Scratchpad CRUD ────────────────────────────────────────────────
/// Save scratchpad strokes for a document (upsert).
Future<void> saveScratchpad(String documentId, String strokesJson) async {
final now = DateTime.now().toIso8601String();
await _database.rawInsert(
'''INSERT INTO scratchpads (id, document_id, strokes_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(document_id) DO UPDATE SET strokes_json = excluded.strokes_json, updated_at = excluded.updated_at''',
[const Uuid().v4(), documentId, strokesJson, now, now],
);
}
/// Load scratchpad strokes for a document.
Future<List<InkStroke>> loadScratchpad(String documentId) async {
final rows = await _database.query(
'scratchpads',
where: 'document_id = ?',
whereArgs: [documentId],
);
if (rows.isEmpty) return [];
final json = rows.first['strokes_json'] as String;
if (json.isEmpty || json == '[]') return [];
final List<dynamic> list = jsonDecode(json) as List<dynamic>;
return list
.map((s) => InkStroke.fromJson(s as Map<String, dynamic>))
.toList();
}
}

View File

@@ -0,0 +1,21 @@
import 'dart:io';
import 'package:flutter/services.dart';
/// Platform OCR backend. Uses Windows built-in OCR on desktop Windows.
class OcrEngine {
static const _channel = MethodChannel('badnote/ocr');
/// Recognize text from a PNG image. Returns null when unavailable or empty.
static Future<String?> recognizeImage(Uint8List pngBytes) async {
if (!Platform.isWindows) return null;
try {
final result = await _channel.invokeMethod<String>('recognize', pngBytes);
final text = result?.trim();
if (text == null || text.isEmpty) return null;
return text;
} catch (_) {
return null;
}
}
}

View File

@@ -0,0 +1,46 @@
import '../models/note.dart';
import '../models/pen_tool.dart';
import 'database_service.dart';
import 'ocr_engine.dart';
import 'stroke_rasterizer.dart';
/// Runs OCR locally: typed text from strokes + handwriting via platform OCR.
class OcrService {
/// Extract searchable text from [note] and merge into the local FTS index.
Future<void> processNote(Note note) async {
final parts = <String>[];
for (final stroke in note.strokes) {
if (stroke.tool == PenTool.text &&
stroke.textContent != null &&
stroke.textContent!.trim().isNotEmpty) {
parts.add(stroke.textContent!.trim());
}
}
final handwritingStrokes = note.strokes
.where(
(s) =>
s.tool != PenTool.eraser &&
s.tool != PenTool.text &&
s.points.isNotEmpty,
)
.toList();
if (handwritingStrokes.isNotEmpty) {
final png = await StrokeRasterizer.render(handwritingStrokes);
if (png != null) {
final recognized = await OcrEngine.recognizeImage(png);
if (recognized != null && recognized.isNotEmpty) {
parts.add(recognized);
}
}
}
final combined = parts.join(' ').trim();
if (combined.isEmpty) return;
final db = await DatabaseService.getInstance();
await db.appendOcrToFts(note.id, combined);
}
}

View File

@@ -0,0 +1,245 @@
import 'dart:io';
import 'dart:math' as math;
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:syncfusion_flutter_pdf/pdf.dart';
import '../models/ink_stroke.dart';
/// Service for PDF operations: file picking, info extraction, and annotation export.
class PdfService {
/// Pick a PDF file path using the cross-platform file_picker.
Future<String?> pickPdfFile() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf'],
);
final files = result?.files;
if (files == null || files.isEmpty) return null;
return files.first.path;
}
/// Get the page count of the PDF at [filePath].
Future<int> getPageCount(String filePath) async {
final bytes = await File(filePath).readAsBytes();
final document = PdfDocument(inputBytes: bytes);
try {
return document.pages.count;
} finally {
document.dispose();
}
}
/// Get basic info about a PDF file: fileName and fileSize.
Future<Map<String, dynamic>> getPdfInfo(String filePath) async {
final file = File(filePath);
final fileSize = await file.length();
return {'fileName': p.basename(filePath), 'fileSize': fileSize};
}
/// Export an annotated PDF by drawing ink strokes onto each page.
///
/// [annotations] maps page index (0-based) to lists of [InkStroke].
/// Stroke coordinates are normalized to [0, 1] relative to the annotation
/// overlay size used during capture, and are scaled to actual PDF page
/// dimensions during export.
///
/// Returns the path to the exported annotated PDF.
Future<String> exportAnnotatedPdf(
String filePath,
Map<int, List<InkStroke>> annotations,
) async {
final bytes = await File(filePath).readAsBytes();
final document = PdfDocument(inputBytes: bytes);
try {
for (final entry in annotations.entries) {
final pageIndex = entry.key;
final strokes = entry.value;
if (strokes.isEmpty) continue;
if (pageIndex >= document.pages.count) continue;
final page = document.pages[pageIndex];
_renderStrokes(page, strokes);
}
final outputDir = await getTemporaryDirectory();
final baseName = p.basenameWithoutExtension(filePath);
final outputPath = p.join(outputDir.path, '${baseName}_annotated.pdf');
final savedBytes = await document.save();
await File(outputPath).writeAsBytes(savedBytes, flush: true);
return outputPath;
} finally {
document.dispose();
}
}
/// Delete a page at [pageIndex]. Returns true on success.
Future<bool> deletePage(String filePath, int pageIndex) async {
try {
final bytes = await File(filePath).readAsBytes();
final document = PdfDocument(inputBytes: bytes);
try {
if (pageIndex < 0 || pageIndex >= document.pages.count) {
return false;
}
document.pages.removeAt(pageIndex);
final outputBytes = await document.save();
await File(filePath).writeAsBytes(outputBytes, flush: true);
return true;
} finally {
document.dispose();
}
} catch (_) {
return false;
}
}
/// Insert a blank A4 page (595 x 842 pt) after [afterIndex].
/// Returns true on success.
Future<bool> insertBlankPage(String filePath, int afterIndex) async {
try {
final bytes = await File(filePath).readAsBytes();
final document = PdfDocument(inputBytes: bytes);
final insertAt = (afterIndex + 1).clamp(0, document.pages.count);
document.pages.insert(insertAt);
final outputBytes = await document.save();
document.dispose();
await File(filePath).writeAsBytes(outputBytes, flush: true);
return true;
} catch (_) {
return false;
}
}
/// Rotate page at [pageIndex] 90 degrees clockwise.
/// Returns true on success.
Future<bool> rotatePage(String filePath, int pageIndex) async {
try {
final bytes = await File(filePath).readAsBytes();
final document = PdfDocument(inputBytes: bytes);
try {
if (pageIndex < 0 || pageIndex >= document.pages.count) {
return false;
}
final page = document.pages[pageIndex];
final current = page.rotation;
// Cycle through: 0 -> 90 -> 180 -> 270 -> 0
switch (current) {
case PdfPageRotateAngle.rotateAngle0:
page.rotation = PdfPageRotateAngle.rotateAngle90;
case PdfPageRotateAngle.rotateAngle90:
page.rotation = PdfPageRotateAngle.rotateAngle180;
case PdfPageRotateAngle.rotateAngle180:
page.rotation = PdfPageRotateAngle.rotateAngle270;
case PdfPageRotateAngle.rotateAngle270:
page.rotation = PdfPageRotateAngle.rotateAngle0;
}
final outputBytes = await document.save();
await File(filePath).writeAsBytes(outputBytes, flush: true);
return true;
} finally {
document.dispose();
}
} catch (_) {
return false;
}
}
/// Draw an image from [imagePath] onto the page at [pageIndex],
/// fitted to the page dimensions while preserving aspect ratio.
/// Returns the [pdfPath] on success, null on failure.
Future<String?> insertImageOnPage(
String pdfPath,
int pageIndex,
String imagePath,
) async {
try {
final pdfBytes = await File(pdfPath).readAsBytes();
final document = PdfDocument(inputBytes: pdfBytes);
try {
if (pageIndex < 0 || pageIndex >= document.pages.count) {
return null;
}
final page = document.pages[pageIndex];
final imageBytes = await File(imagePath).readAsBytes();
final pdfImage = PdfBitmap(imageBytes);
final pageSize = page.getClientSize();
// Fit the image to the page while preserving its aspect ratio
// (letterboxed and centered), rather than stretching it to fill.
final imageWidth = pdfImage.width.toDouble();
final imageHeight = pdfImage.height.toDouble();
final scale = (imageWidth <= 0 || imageHeight <= 0)
? 1.0
: math.min(
pageSize.width / imageWidth,
pageSize.height / imageHeight,
);
final drawWidth = imageWidth * scale;
final drawHeight = imageHeight * scale;
final left = (pageSize.width - drawWidth) / 2;
final top = (pageSize.height - drawHeight) / 2;
page.graphics.drawImage(
pdfImage,
Rect.fromLTWH(left, top, drawWidth, drawHeight),
);
final outputBytes = await document.save();
await File(pdfPath).writeAsBytes(outputBytes, flush: true);
return pdfPath;
} finally {
document.dispose();
}
} catch (_) {
return null;
}
}
/// Renders [strokes] onto a PDF [page] using normalized [0, 1] coordinates
/// scaled to the actual page dimensions.
void _renderStrokes(PdfPage page, List<InkStroke> strokes) {
final graphics = page.graphics;
final pageSize = page.getClientSize();
for (final stroke in strokes) {
if (stroke.points.isEmpty) continue;
final color = stroke.color;
final r = (color >> 16) & 0xFF;
final g = (color >> 8) & 0xFF;
final b = color & 0xFF;
final a = (color >> 24) & 0xFF;
final pen = PdfPen(PdfColor(r, g, b, a));
pen.width = stroke.strokeWidth.clamp(1.0, 8.0);
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),
);
}
}
}
}
}

View File

@@ -0,0 +1,62 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../models/ink_point.dart';
import '../models/pen_tool.dart';
import '../models/pointer_device_kind.dart';
class PenInputService {
final StreamController<InkPoint> _pointController =
StreamController<InkPoint>.broadcast();
Stream<InkPoint> get pointStream => _pointController.stream;
PenTool currentTool = PenTool.pen;
Color currentColor = Colors.black;
double currentStrokeWidth = 2.0;
void addPoint(InkPoint point) {
_pointController.add(point);
}
InputDeviceKind mapFlutterKind(PointerDeviceKind kind) {
switch (kind) {
case PointerDeviceKind.touch:
return InputDeviceKind.touch;
case PointerDeviceKind.mouse:
return InputDeviceKind.mouse;
case PointerDeviceKind.stylus:
return InputDeviceKind.stylus;
case PointerDeviceKind.invertedStylus:
return InputDeviceKind.invertedStylus;
case PointerDeviceKind.trackpad:
return InputDeviceKind.trackpad;
case PointerDeviceKind.unknown:
return InputDeviceKind.unknown;
}
}
InkPoint fromPointerEvent(PointerEvent event) {
// Devices without real pressure support (mouse, basic touch) report a
// degenerate range where pressureMin == pressureMax, which can yield a
// pressure of 0.0 and produce zero-width strokes. In that case fall back
// to a neutral mid-pressure value so strokes remain visible.
final pressure = event.pressureMin == event.pressureMax
? 0.5
: event.pressure;
return InkPoint(
x: event.localPosition.dx,
y: event.localPosition.dy,
pressure: pressure,
tilt: event is PointerMoveEvent ? event.tilt : 0.0,
timestamp: event.timeStamp.inMicroseconds,
pointerDeviceKind: mapFlutterKind(event.kind),
);
}
void dispose() {
_pointController.close();
}
}

View File

@@ -0,0 +1,293 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';
/// Service for processing PPTX files: text extraction, image conversion, file picking.
///
/// PPTX files are ZIP archives containing XML. We extract text from
/// `ppt/slides/slide*.xml` `<a:t>` elements and convert slides to images
/// using LibreOffice (headless) or generate placeholder images as fallback.
class PptxService {
static const _uuid = Uuid();
/// Extract all text content from a PPTX file.
///
/// PPTX is a ZIP archive. Slide text lives in `ppt/slides/slide*.xml`
/// inside `<a:t>` (ASCII text) elements within `<a:r>` (run) or
/// `<a:p>` (paragraph) nodes.
Future<String> extractText(String pptxPath) async {
final tmpDir = await _makeTmpDir('pptx_text');
try {
// Unzip the PPTX
final unzipResult = await Process.run('unzip', [
'-o',
'-q',
pptxPath,
'-d',
tmpDir.path,
]);
if (unzipResult.exitCode != 0) {
return '';
}
// Find all slide XML files
final slidesDir = Directory(p.join(tmpDir.path, 'ppt', 'slides'));
if (!await slidesDir.exists()) return '';
final slideFiles = await slidesDir
.list()
.where((f) => f.path.contains(RegExp(r'slide\d+\.xml$')))
.toList();
// Sort by slide number
slideFiles.sort((a, b) {
final aNum = _extractSlideNumber(a.path);
final bNum = _extractSlideNumber(b.path);
return aNum.compareTo(bNum);
});
final buffer = StringBuffer();
for (final slideFile in slideFiles) {
final xml = await File(slideFile.path).readAsString();
final slideText = _extractTextFromXml(xml);
if (slideText.isNotEmpty) {
final num = _extractSlideNumber(slideFile.path);
buffer.writeln('--- Slide $num ---');
buffer.writeln(slideText);
buffer.writeln();
}
}
return buffer.toString().trim();
} catch (_) {
return '';
} finally {
// Cleanup
try {
await tmpDir.delete(recursive: true);
} catch (_) {}
}
}
/// Convert PPTX slides to a list of image file paths.
///
/// Attempts LibreOffice headless conversion first. Falls back to
/// generating placeholder slide images (colored rectangles with slide numbers).
Future<List<String>> convertToImages(String pptxPath) async {
// Try LibreOffice first
final loImages = await _convertViaLibreOffice(pptxPath);
if (loImages.isNotEmpty) return loImages;
// Fallback: generate placeholder images
return _generatePlaceholderImages(pptxPath);
}
/// Open a file picker dialog and return the selected PPTX path, or null.
///
/// Uses the cross-platform file_picker package.
Future<String?> openPptxFile() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pptx', 'ppt'],
);
final files = result?.files;
if (files == null || files.isEmpty) return null;
return files.first.path;
}
// ---------------------------------------------------------------------------
// Implementation helpers
// ---------------------------------------------------------------------------
/// Extract text from PPTX slide XML by finding `<a:t>` content.
String _extractTextFromXml(String xml) {
final lines = <String>[];
// Match <a:t>...</a:t> — handles both <a:t>text</a:t> and <a:t xml:space="preserve">text</a:t>
final regex = RegExp(r'<a:t[^>]*>(.*?)</a:t>', dotAll: true);
for (final match in regex.allMatches(xml)) {
final text = match.group(1) ?? '';
if (text.trim().isNotEmpty) {
lines.add(text.trim());
}
}
return lines.join('\n');
}
int _extractSlideNumber(String path) {
final match = RegExp(r'slide(\d+)\.xml$').firstMatch(path);
if (match != null) return int.parse(match.group(1)!);
return 0;
}
/// Try converting via LibreOffice headless.
Future<List<String>> _convertViaLibreOffice(String pptxPath) async {
try {
// Check if LibreOffice is available
final which = await Process.run('which', ['libreoffice']);
if (which.exitCode != 0) return [];
final outDir = await _makeTmpDir('pptx_images');
final result = await Process.run('libreoffice', [
'--headless',
'--convert-to',
'png',
'--outdir',
outDir.path,
pptxPath,
]);
if (result.exitCode != 0) return [];
// Collect generated PNGs, sorted by name
final pngs = await outDir
.list()
.where((f) => f.path.endsWith('.png'))
.map((f) => f.path)
.toList();
pngs.sort();
// Move to a persistent temp location so outDir can be cleaned up
final persistDir = await _makeTmpDir('pptx_slides');
final persistentPaths = <String>[];
for (var i = 0; i < pngs.length; i++) {
final src = File(pngs[i]);
final dst = p.join(persistDir.path, 'slide_${i + 1}.png');
await src.copy(dst);
persistentPaths.add(dst);
}
// Clean up the LibreOffice output dir
try {
await outDir.delete(recursive: true);
} catch (_) {}
return persistentPaths;
} catch (_) {
return [];
}
}
/// Generate placeholder slide images when LibreOffice is not available.
///
/// Uses ImageMagick `convert` to create PNG files with slide numbers.
/// If ImageMagick is not available, writes minimal 1x1 white PNGs as
/// last-resort placeholders.
Future<List<String>> _generatePlaceholderImages(String pptxPath) async {
// Count slides by unzipping and counting slide XML files
final slideCount = await _countSlides(pptxPath);
if (slideCount == 0) return [];
final outDir = await _makeTmpDir('pptx_placeholders');
final paths = <String>[];
// Try ImageMagick
final hasConvert = await _hasCommand('convert');
for (var i = 1; i <= slideCount; i++) {
final path = p.join(outDir.path, 'slide_$i.png');
if (hasConvert) {
await _generateWithImageMagick(path, i, slideCount);
} else {
await _writeMinimalPng(path);
}
paths.add(path);
}
return paths;
}
Future<int> _countSlides(String pptxPath) async {
final tmpDir = await _makeTmpDir('pptx_count');
try {
await Process.run('unzip', ['-o', '-q', pptxPath, '-d', tmpDir.path]);
final slidesDir = Directory(p.join(tmpDir.path, 'ppt', 'slides'));
if (!await slidesDir.exists()) return 0;
final count = await slidesDir
.list()
.where((f) => f.path.contains(RegExp(r'slide\d+\.xml$')))
.length;
return count;
} catch (_) {
return 0;
} finally {
try {
await tmpDir.delete(recursive: true);
} catch (_) {}
}
}
Future<bool> _hasCommand(String cmd) async {
try {
final result = await Process.run('which', [cmd]);
return result.exitCode == 0;
} catch (_) {
return false;
}
}
Future<void> _generateWithImageMagick(
String outPath,
int slideNum,
int total,
) async {
// Light pastel background with slide number
final hue = ((slideNum - 1) * 137) % 360; // golden-angle spacing
await Process.run('convert', [
'-size',
'1920x1080',
'xc:hsl($hue, 60%, 92%)',
'-gravity',
'center',
'-pointsize',
'120',
'-fill',
'hsl($hue, 30%, 40%)',
'-annotate',
'+0+0',
'Slide $slideNum / $total',
outPath,
]);
}
/// Write a minimal valid 1x1 white PNG as an absolute last resort.
/// This is a hand-crafted PNG (IHDR + single white pixel IDAT + IEND).
Future<void> _writeMinimalPng(String path) async {
// Minimal valid 1x1 white PNG
const pngBytes = <int>[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
// IHDR chunk
0x00, 0x00, 0x00, 0x0D, // length = 13
0x49, 0x48, 0x44, 0x52, // "IHDR"
0x00, 0x00, 0x00, 0x01, // width = 1
0x00, 0x00, 0x00, 0x01, // height = 1
0x08, 0x02, // bit depth = 8, color type = 2 (RGB)
0x00, 0x00, 0x00, // compression, filter, interlace
0x90, 0x77, 0x53, 0xDE, // CRC
// IDAT chunk
0x00, 0x00, 0x00, 0x0C, // length = 12
0x49, 0x44, 0x41, 0x54, // "IDAT"
0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00,
0x01, 0x01, 0x01, 0x00, // compressed data
0x18, 0xDD, 0x8D, 0xB4, // CRC
// IEND chunk
0x00, 0x00, 0x00, 0x00, // length = 0
0x49, 0x45, 0x4E, 0x44, // "IEND"
0xAE, 0x42, 0x60, 0x82, // CRC
];
await File(path).writeAsBytes(pngBytes);
}
Future<Directory> _makeTmpDir(String prefix) async {
final base = await getTemporaryDirectory();
final dir = Directory(p.join(base.path, '${prefix}_${_uuid.v4()}'));
await dir.create(recursive: true);
return dir;
}
}

View File

@@ -0,0 +1,302 @@
import 'dart:math';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
import '../models/ink_point.dart';
import '../models/ink_stroke.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
/// Renders ink strokes to a PNG byte array for local OCR.
class StrokeRasterizer {
static const _padding = 24.0;
static const _defaultPressureCurve = PressureCurve.linear;
/// Render [strokes] onto a white canvas and return PNG bytes, or null if empty.
static Future<Uint8List?> render(List<InkStroke> strokes) async {
final drawable = strokes
.where((s) => s.tool != PenTool.eraser && s.points.isNotEmpty)
.toList();
if (drawable.isEmpty) return null;
final bounds = _computeBounds(drawable);
if (bounds == null) return null;
final width = (bounds.width + _padding * 2).ceil().clamp(1, 4096);
final height = (bounds.height + _padding * 2).ceil().clamp(1, 4096);
final offset = Offset(_padding - bounds.left, _padding - bounds.top);
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawRect(
Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()),
Paint()..color = Colors.white,
);
for (final stroke in drawable) {
_drawStroke(canvas, stroke, offset);
}
final picture = recorder.endRecording();
final image = await picture.toImage(width, height);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
return byteData?.buffer.asUint8List();
}
static Rect? _computeBounds(List<InkStroke> strokes) {
double? minX, minY, maxX, maxY;
for (final stroke in strokes) {
for (final p in stroke.points) {
minX = minX == null ? p.x : min(minX, p.x);
minY = minY == null ? p.y : min(minY, p.y);
maxX = maxX == null ? p.x : max(maxX, p.x);
maxY = maxY == null ? p.y : max(maxY, p.y);
}
}
if (minX == null || minY == null || maxX == null || maxY == null) {
return null;
}
return Rect.fromLTRB(minX, minY, maxX, maxY);
}
static List<InkPoint> _offsetPoints(List<InkPoint> points, Offset offset) {
return points
.map(
(p) => InkPoint(
x: p.x + offset.dx,
y: p.y + offset.dy,
pressure: p.pressure,
timestamp: p.timestamp,
),
)
.toList();
}
static void _drawStroke(Canvas canvas, InkStroke stroke, Offset offset) {
final points = _offsetPoints(stroke.points, offset);
final color = Color(stroke.color);
final tool = stroke.tool;
switch (tool) {
case PenTool.pen:
case PenTool.marker:
case PenTool.highlighter:
_drawFreehand(canvas, points, tool, color, stroke.strokeWidth);
break;
case PenTool.rectangle:
if (points.length >= 2) {
_drawRect(canvas, points, color, stroke.strokeWidth, stroke.filled);
} else {
_drawFreehand(canvas, points, tool, color, stroke.strokeWidth);
}
break;
case PenTool.ellipse:
if (points.length >= 2) {
_drawOval(canvas, points, color, stroke.strokeWidth, stroke.filled);
} else {
_drawFreehand(canvas, points, tool, color, stroke.strokeWidth);
}
break;
case PenTool.line:
if (points.length >= 2) {
_drawLine(canvas, points, color, stroke.strokeWidth);
} else {
_drawFreehand(canvas, points, tool, color, stroke.strokeWidth);
}
break;
case PenTool.arrow:
if (points.length >= 2) {
_drawArrow(canvas, points, color, stroke.strokeWidth);
} else {
_drawFreehand(canvas, points, tool, color, stroke.strokeWidth);
}
break;
case PenTool.text:
if (stroke.textContent != null && stroke.textContent!.isNotEmpty) {
_drawText(
canvas,
points,
stroke.textContent!,
stroke.fontSize,
color,
);
}
break;
case PenTool.eraser:
break;
}
}
static void _drawFreehand(
Canvas canvas,
List<InkPoint> points,
PenTool tool,
Color color,
double strokeWidth,
) {
final pfPoints = points
.map(
(p) => pf.Point(
p.x,
p.y,
_defaultPressureCurve.apply(p.pressure).clamp(0.0, 1.0),
),
)
.toList();
final thinning = (tool == PenTool.marker || tool == PenTool.highlighter)
? 0.0
: 0.7;
final outline = pf.getStroke(
pfPoints,
size: strokeWidth,
thinning: thinning,
smoothing: 0.5,
streamline: 0.5,
simulatePressure: tool != PenTool.marker && tool != PenTool.highlighter,
isComplete: true,
);
if (outline.isEmpty) return;
final path = Path()..moveTo(outline[0].x, outline[0].y);
for (var i = 1; i < outline.length; i++) {
path.lineTo(outline[i].x, outline[i].y);
}
path.close();
canvas.drawPath(
path,
Paint()
..color = color
..style = PaintingStyle.fill
..isAntiAlias = true,
);
}
static void _drawRect(
Canvas canvas,
List<InkPoint> points,
Color color,
double strokeWidth,
bool filled,
) {
final rect = Rect.fromPoints(
Offset(points[0].x, points[0].y),
Offset(points[1].x, points[1].y),
);
canvas.drawRect(
rect,
Paint()
..color = color
..strokeWidth = strokeWidth
..style = filled ? PaintingStyle.fill : PaintingStyle.stroke
..isAntiAlias = true,
);
}
static void _drawOval(
Canvas canvas,
List<InkPoint> points,
Color color,
double strokeWidth,
bool filled,
) {
final rect = Rect.fromPoints(
Offset(points[0].x, points[0].y),
Offset(points[1].x, points[1].y),
);
canvas.drawOval(
rect,
Paint()
..color = color
..strokeWidth = strokeWidth
..style = filled ? PaintingStyle.fill : PaintingStyle.stroke
..isAntiAlias = true,
);
}
static void _drawLine(
Canvas canvas,
List<InkPoint> points,
Color color,
double strokeWidth,
) {
canvas.drawLine(
Offset(points[0].x, points[0].y),
Offset(points[1].x, points[1].y),
Paint()
..color = color
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round
..isAntiAlias = true,
);
}
static void _drawArrow(
Canvas canvas,
List<InkPoint> points,
Color color,
double strokeWidth,
) {
final start = Offset(points[0].x, points[0].y);
final end = Offset(points[1].x, points[1].y);
canvas.drawLine(
start,
end,
Paint()
..color = color
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round
..isAntiAlias = true,
);
final angle = atan2(end.dy - start.dy, end.dx - start.dx);
const headLength = 12.0;
const headAngle = pi / 6;
final p1 =
end +
Offset(
-headLength * cos(angle - headAngle),
-headLength * sin(angle - headAngle),
);
final p2 =
end +
Offset(
-headLength * cos(angle + headAngle),
-headLength * sin(angle + headAngle),
);
final head = Path()
..moveTo(end.dx, end.dy)
..lineTo(p1.dx, p1.dy)
..lineTo(p2.dx, p2.dy)
..close();
canvas.drawPath(
head,
Paint()
..color = color
..style = PaintingStyle.fill,
);
}
static void _drawText(
Canvas canvas,
List<InkPoint> points,
String text,
double fontSize,
Color color,
) {
if (points.isEmpty) return;
final painter = TextPainter(
text: TextSpan(
text: text,
style: TextStyle(color: color, fontSize: fontSize),
),
textDirection: TextDirection.ltr,
)..layout();
painter.paint(canvas, Offset(points[0].x, points[0].y));
}
}

View File

@@ -0,0 +1,130 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:syncfusion_pdfviewer_platform_interface/pdfviewer_platform_interface.dart';
/// Service for generating, caching, and retrieving page thumbnails.
class ThumbnailService {
static Future<File> _thumbnailFile(String documentId, int pageIndex) async {
final appDir = await getApplicationDocumentsDirectory();
final dir = Directory('${appDir.path}/thumbnails/$documentId');
if (!await dir.exists()) await dir.create(recursive: true);
return File('${dir.path}/$pageIndex.png');
}
static Future<Uint8List?> _rgbaToPng(
Uint8List rgba,
int width,
int height,
) async {
final completer = Completer<ui.Image>();
ui.decodeImageFromPixels(
rgba,
width,
height,
ui.PixelFormat.bgra8888,
completer.complete,
rowBytes: width * 4,
);
final image = await completer.future;
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
return byteData?.buffer.asUint8List();
}
/// Render a single PDF page to PNG bytes at [maxWidth] pixel width.
/// Returns null on failure.
static Future<Uint8List?> generate(
String filePath,
int pageIndex, {
int maxWidth = 160,
}) async {
// Stable, low-collision renderer handle key for this file. Plain
// `filePath.hashCode` can collide between different paths; combining it
// with the path length and basename (no extra deps beyond `path`)
// drastically reduces the chance two distinct files share a handle.
final documentId =
'thumb-${filePath.hashCode}-${filePath.length}-${p.basename(filePath)}';
try {
final bytes = await File(filePath).readAsBytes();
final pageCountStr = await PdfViewerPlatform.instance
.initializePdfRenderer(bytes, documentId);
if (pageCountStr == null) return null;
final pageCount = int.tryParse(pageCountStr);
if (pageCount == null || pageIndex < 0 || pageIndex >= pageCount) {
await PdfViewerPlatform.instance.closeDocument(documentId);
return null;
}
final pagesHeight = await PdfViewerPlatform.instance.getPagesHeight(
documentId,
);
final pagesWidth = await PdfViewerPlatform.instance.getPagesWidth(
documentId,
);
if (pagesHeight == null || pagesWidth == null) {
await PdfViewerPlatform.instance.closeDocument(documentId);
return null;
}
final pageHeight = pagesHeight[pageIndex] as double;
final pageWidth = pagesWidth[pageIndex] as double;
final thumbnailHeight = (maxWidth * pageHeight / pageWidth).round();
final rgba = await PdfViewerPlatform.instance.getPage(
pageIndex + 1,
maxWidth,
thumbnailHeight,
documentId,
);
await PdfViewerPlatform.instance.closeDocument(documentId);
if (rgba == null) return null;
return _rgbaToPng(rgba, maxWidth, thumbnailHeight);
} catch (_) {
try {
await PdfViewerPlatform.instance.closeDocument(documentId);
} catch (_) {}
return null;
}
}
/// Persist thumbnail bytes to disk and return the file.
static Future<File?> cacheThumbnail(
String documentId,
int pageIndex,
Uint8List data,
) async {
final file = await _thumbnailFile(documentId, pageIndex);
await file.writeAsBytes(data);
return file;
}
/// Whether a cached thumbnail exists on disk.
static Future<bool> hasCached(String documentId, int pageIndex) async {
return (await _thumbnailFile(documentId, pageIndex)).exists();
}
/// Return the cached file if it exists, otherwise null.
static Future<File?> getCached(String documentId, int pageIndex) async {
final file = await _thumbnailFile(documentId, pageIndex);
return (await file.exists()) ? file : null;
}
/// Delete all cached thumbnails for [documentId].
static Future<void> invalidateAll(String documentId) async {
final appDir = await getApplicationDocumentsDirectory();
final dir = Directory('${appDir.path}/thumbnails/$documentId');
if (await dir.exists()) await dir.delete(recursive: true);
}
/// Invalidate a single page thumbnail.
static Future<void> invalidatePage(String documentId, int pageIndex) async {
final file = await _thumbnailFile(documentId, pageIndex);
if (await file.exists()) await file.delete();
}
}

View File

@@ -0,0 +1,102 @@
import '../models/ink_stroke.dart';
/// Manages undo/redo state for ink strokes.
///
/// Each action records a stroke that was added or removed.
/// [undo] returns the inverse of the last action (remove if add, add if remove).
/// [redo] re-applies the undone action.
class UndoManager {
final List<_UndoAction> _undoStack = [];
final List<_UndoAction> _redoStack = [];
final List<InkStroke> _strokes = [];
/// The current list of strokes (read-only view).
List<InkStroke> get currentStrokes => List.unmodifiable(_strokes);
bool get canUndo => _undoStack.isNotEmpty;
bool get canRedo => _redoStack.isNotEmpty;
/// Records that a new stroke was added to the canvas.
void addStroke(InkStroke stroke) {
_strokes.add(stroke);
_undoStack.add(_UndoAction(type: _ActionType.add, stroke: stroke));
_redoStack.clear();
}
/// Records that a stroke was removed from the canvas.
/// Also handles partial-eraser replacements: removes [stroke] and adds
/// [replacements] (which may be empty if fully erased, or 1-2 sub-strokes).
void removeStroke(
InkStroke stroke, {
List<InkStroke> replacements = const [],
}) {
_strokes.removeWhere((s) => s.id == stroke.id);
_strokes.addAll(replacements);
_undoStack.add(
_UndoAction(
type: _ActionType.remove,
stroke: stroke,
replacements: replacements,
),
);
_redoStack.clear();
}
/// Undoes the last action. Returns the stroke that was affected and needs
/// to be reversed on the canvas, or `null` if nothing to undo.
///
/// For add actions: the stroke should be removed from the canvas.
/// For remove actions: the stroke (and its replacements) should be restored.
InkStroke? undo() {
if (_undoStack.isEmpty) return null;
final action = _undoStack.removeLast();
_redoStack.add(action);
switch (action.type) {
case _ActionType.add:
_strokes.removeWhere((s) => s.id == action.stroke.id);
return action.stroke;
case _ActionType.remove:
// Remove the replacements that were added during the original remove
for (final r in action.replacements) {
_strokes.removeWhere((s) => s.id == r.id);
}
_strokes.add(action.stroke);
return action.stroke;
}
}
/// Redoes the last undone action. Returns the stroke that was affected, or
/// `null` if nothing to redo.
InkStroke? redo() {
if (_redoStack.isEmpty) return null;
final action = _redoStack.removeLast();
_undoStack.add(action);
switch (action.type) {
case _ActionType.add:
_strokes.add(action.stroke);
return action.stroke;
case _ActionType.remove:
_strokes.removeWhere((s) => s.id == action.stroke.id);
_strokes.addAll(action.replacements);
return action.stroke;
}
}
}
enum _ActionType { add, remove }
class _UndoAction {
final _ActionType type;
final InkStroke stroke;
final List<InkStroke> replacements;
_UndoAction({
required this.type,
required this.stroke,
this.replacements = const [],
});
}

View File

@@ -0,0 +1,70 @@
import '../models/ink_point.dart';
/// Stabilization level for hand-drawn strokes.
enum StabilizationLevel { none, light, medium, heavy }
/// Smooths pen input using an Exponential Moving Average (EMA) filter.
///
/// - **none**: no smoothing (passthrough)
/// - **light**: alpha = 0.6 (subtle smoothing)
/// - **medium**: alpha = 0.4 (moderate smoothing)
/// - **heavy**: alpha = 0.25 (strong smoothing, removes most tremor)
///
/// The formula applied to each coordinate independently:
/// x_smoothed = alpha * x_raw + (1 - alpha) * x_prev
class StrokeStabilizer {
final StabilizationLevel level;
double? _prevX;
double? _prevY;
StrokeStabilizer({this.level = StabilizationLevel.none});
double get _alpha {
switch (level) {
case StabilizationLevel.none:
return 1.0;
case StabilizationLevel.light:
return 0.6;
case StabilizationLevel.medium:
return 0.4;
case StabilizationLevel.heavy:
return 0.25;
}
}
/// Filters a raw point through the EMA, returning a smoothed point.
/// Returns the raw point unchanged if level is [StabilizationLevel.none].
InkPoint filter(InkPoint rawPoint) {
if (level == StabilizationLevel.none) return rawPoint;
final alpha = _alpha;
if (_prevX == null || _prevY == null) {
_prevX = rawPoint.x;
_prevY = rawPoint.y;
return rawPoint;
}
final smoothedX = alpha * rawPoint.x + (1 - alpha) * _prevX!;
final smoothedY = alpha * rawPoint.y + (1 - alpha) * _prevY!;
_prevX = smoothedX;
_prevY = smoothedY;
return InkPoint(
x: smoothedX,
y: smoothedY,
pressure: rawPoint.pressure,
tilt: rawPoint.tilt,
timestamp: rawPoint.timestamp,
pointerDeviceKind: rawPoint.pointerDeviceKind,
);
}
/// Resets the filter state. Call when starting a new stroke.
void reset() {
_prevX = null;
_prevY = null;
}
}

View File

@@ -0,0 +1,433 @@
import 'package:flutter/material.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
import '../models/pen_tool.dart';
import '../models/pressure_curve.dart';
import '../utils/stroke_stabilizer.dart';
import '../widgets/ink_canvas.dart';
import 'color_preset_bar.dart';
/// Shared annotation toolbar used by note editor, PDF annotator, and PPT annotator.
class AnnotationToolbar extends StatelessWidget {
final PenTool currentTool;
final Color currentColor;
final double currentStrokeWidth;
final bool filled;
final PressureCurveType pressureCurveType;
final StabilizationLevel stabilizationLevel;
final bool canUndo;
final bool canRedo;
final ValueChanged<PenTool> onToolChanged;
final ValueChanged<Color> onColorChanged;
final ValueChanged<double> onStrokeWidthChanged;
final ValueChanged<bool> onFilledChanged;
final ValueChanged<PressureCurveType> onPressureCurveChanged;
final ValueChanged<StabilizationLevel> onStabilizationChanged;
final VoidCallback? onUndo;
final VoidCallback? onRedo;
final VoidCallback? onPreviousPage;
final VoidCallback? onNextPage;
final String? pageInfo;
final InteractionMode interactionMode;
final ValueChanged<InteractionMode>? onInteractionModeChanged;
final double? zoomLevel;
final VoidCallback? onZoomIn;
final VoidCallback? onZoomOut;
final VoidCallback? onZoomFitWidth;
final String? zoomLabel;
const AnnotationToolbar({
super.key,
required this.currentTool,
required this.currentColor,
required this.currentStrokeWidth,
this.filled = false,
required this.pressureCurveType,
required this.stabilizationLevel,
required this.canUndo,
required this.canRedo,
required this.onToolChanged,
required this.onColorChanged,
required this.onStrokeWidthChanged,
required this.onFilledChanged,
required this.onPressureCurveChanged,
required this.onStabilizationChanged,
this.onUndo,
this.onRedo,
this.onPreviousPage,
this.onNextPage,
this.pageInfo,
this.interactionMode = InteractionMode.draw,
this.onInteractionModeChanged,
this.zoomLevel,
this.onZoomIn,
this.onZoomOut,
this.onZoomFitWidth,
this.zoomLabel,
});
static const _toolDefinitions = [
_ToolDef(PenTool.pen, Icons.edit, 'Pen'),
_ToolDef(PenTool.marker, Icons.highlight, 'Marker'),
_ToolDef(PenTool.highlighter, Icons.border_color, 'Highlighter'),
_ToolDef(PenTool.eraser, Icons.auto_fix_normal, 'Eraser'),
_ToolDef(PenTool.rectangle, Icons.rectangle_outlined, 'Rectangle'),
_ToolDef(PenTool.ellipse, Icons.circle_outlined, 'Ellipse'),
_ToolDef(PenTool.line, Icons.horizontal_rule, 'Line'),
_ToolDef(PenTool.arrow, Icons.arrow_right_alt, 'Arrow'),
_ToolDef(PenTool.text, Icons.text_fields, 'Text'),
];
bool get _isShapeTool {
return currentTool == PenTool.rectangle || currentTool == PenTool.ellipse;
}
void _showColorPicker(BuildContext context) {
showDialog(
context: context,
builder: (context) {
Color pickerColor = currentColor;
return AlertDialog(
title: const Text('Pick a color'),
content: SingleChildScrollView(
child: ColorPicker(
pickerColor: pickerColor,
onColorChanged: (color) => pickerColor = color,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
onColorChanged(pickerColor);
Navigator.of(context).pop();
},
child: const Text('OK'),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Row 1: Mode toggle + Tools + color presets + stroke width + undo/redo
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
// Pen/Navigate mode toggle
if (onInteractionModeChanged != null) ...[
Tooltip(
message: interactionMode == InteractionMode.draw
? 'Drawing mode'
: 'Navigate mode',
child: GestureDetector(
onTap: () {
final newMode = interactionMode == InteractionMode.draw
? InteractionMode.navigate
: InteractionMode.draw;
onInteractionModeChanged!(newMode);
},
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: interactionMode == InteractionMode.draw
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
interactionMode == InteractionMode.draw
? Icons.edit
: Icons.pan_tool,
size: 20,
color: interactionMode == InteractionMode.draw
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(
context,
).colorScheme.onTertiaryContainer,
),
),
),
),
const SizedBox(width: 6),
],
for (final def in _toolDefinitions) ...[
_ToolButton(
icon: def.icon,
label: def.label,
isSelected: currentTool == def.tool,
onPressed: () => onToolChanged(def.tool),
),
const SizedBox(width: 4),
],
// Filled toggle for shape tools
if (_isShapeTool) ...[
const SizedBox(width: 4),
Tooltip(
message: filled ? 'Filled' : 'Outline',
child: GestureDetector(
onTap: () => onFilledChanged(!filled),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: filled
? Theme.of(context).colorScheme.primaryContainer
: Colors.transparent,
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: filled
? Theme.of(context).colorScheme.primary
: Colors.grey.shade400,
),
),
child: Icon(
filled ? Icons.square : Icons.square_outlined,
size: 16,
color: filled
? Theme.of(context).colorScheme.onPrimaryContainer
: Colors.grey.shade600,
),
),
),
),
],
const SizedBox(width: 8),
ColorPresetBar(
selectedColor: currentColor,
onColorSelected: onColorChanged,
onOpenFullPicker: () => _showColorPicker(context),
),
const SizedBox(width: 8),
SizedBox(
width: 120,
child: Slider(
value: currentStrokeWidth,
min: 1.0,
max: 20.0,
divisions: 19,
label: currentStrokeWidth.toStringAsFixed(1),
onChanged: onStrokeWidthChanged,
),
),
IconButton(
icon: const Icon(Icons.undo),
tooltip: 'Undo',
iconSize: 20,
padding: const EdgeInsets.all(4),
onPressed: canUndo ? onUndo : null,
),
IconButton(
icon: const Icon(Icons.redo),
tooltip: 'Redo',
iconSize: 20,
padding: const EdgeInsets.all(4),
onPressed: canRedo ? onRedo : null,
),
// Page navigation (optional, for PDF/PPT)
if (onPreviousPage != null) ...[
IconButton(
icon: const Icon(Icons.navigate_before),
tooltip: 'Previous page',
iconSize: 20,
padding: const EdgeInsets.all(4),
onPressed: onPreviousPage,
),
if (pageInfo != null)
Text(pageInfo!, style: const TextStyle(fontSize: 12)),
IconButton(
icon: const Icon(Icons.navigate_next),
tooltip: 'Next page',
iconSize: 20,
padding: const EdgeInsets.all(4),
onPressed: onNextPage,
),
],
// Zoom controls (optional)
if (onZoomIn != null) ...[
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.zoom_out),
tooltip: 'Zoom out',
iconSize: 20,
padding: const EdgeInsets.all(4),
onPressed: onZoomOut,
),
if (zoomLabel != null)
Text(zoomLabel!, style: const TextStyle(fontSize: 11)),
IconButton(
icon: const Icon(Icons.zoom_in),
tooltip: 'Zoom in',
iconSize: 20,
padding: const EdgeInsets.all(4),
onPressed: onZoomIn,
),
if (onZoomFitWidth != null)
IconButton(
icon: const Icon(Icons.fit_screen),
tooltip: 'Fit to width',
iconSize: 20,
padding: const EdgeInsets.all(4),
onPressed: onZoomFitWidth,
),
],
],
),
),
// Row 2: Pressure curve + stabilization selectors
Row(
children: [
const Icon(Icons.touch_app, size: 14, color: Colors.grey),
const SizedBox(width: 4),
const Text(
'Pressure:',
style: TextStyle(fontSize: 11, color: Colors.grey),
),
const SizedBox(width: 4),
_buildSegmentedButton<PressureCurveType>(
context: context,
options: const {
PressureCurveType.linear: 'Lin',
PressureCurveType.soft: 'Soft',
PressureCurveType.hard: 'Hard',
},
selected: pressureCurveType,
onChanged: onPressureCurveChanged,
),
const SizedBox(width: 16),
const Icon(Icons.gesture, size: 14, color: Colors.grey),
const SizedBox(width: 4),
const Text(
'Smooth:',
style: TextStyle(fontSize: 11, color: Colors.grey),
),
const SizedBox(width: 4),
_buildSegmentedButton<StabilizationLevel>(
context: context,
options: const {
StabilizationLevel.none: 'Off',
StabilizationLevel.light: 'Low',
StabilizationLevel.medium: 'Med',
StabilizationLevel.heavy: 'High',
},
selected: stabilizationLevel,
onChanged: onStabilizationChanged,
),
],
),
],
),
);
}
Widget _buildSegmentedButton<T>({
required BuildContext context,
required Map<T, String> options,
required T selected,
required ValueChanged<T> onChanged,
}) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Theme.of(context).colorScheme.outline),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: options.entries.map((entry) {
final isSelected = entry.key == selected;
return GestureDetector(
onTap: () => onChanged(entry.key),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primaryContainer
: Colors.transparent,
borderRadius: BorderRadius.circular(5),
),
child: Text(
entry.value,
style: TextStyle(
fontSize: 11,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSurface,
),
),
),
);
}).toList(),
),
);
}
}
class _ToolDef {
final PenTool tool;
final IconData icon;
final String label;
const _ToolDef(this.tool, this.icon, this.label);
}
class _ToolButton extends StatelessWidget {
final IconData icon;
final String label;
final bool isSelected;
final VoidCallback onPressed;
const _ToolButton({
required this.icon,
required this.label,
required this.isSelected,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: label,
child: Material(
color: isSelected
? Theme.of(context).colorScheme.primaryContainer
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
icon,
size: 20,
color: isSelected
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSurface,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
/// A row of preset color circles with a palette icon to open the full picker.
class ColorPresetBar extends StatelessWidget {
final Color selectedColor;
final ValueChanged<Color> onColorSelected;
final VoidCallback onOpenFullPicker;
const ColorPresetBar({
super.key,
required this.selectedColor,
required this.onColorSelected,
required this.onOpenFullPicker,
});
static const List<Color> presetColors = [
Colors.black,
Color(0xFFE53935), // red
Color(0xFF1E88E5), // blue
Color(0xFF43A047), // green
Color(0xFFFB8C00), // orange
Color(0xFF8E24AA), // purple
Color(0xFF6D4C41), // brown
Colors.white,
];
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
for (final color in presetColors) ...[
MouseRegion(
cursor: SystemMouseCursors.click,
child: InkWell(
onTap: () => onColorSelected(color),
borderRadius: BorderRadius.circular(11),
child: Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: selectedColor == color
? Theme.of(context).colorScheme.primary
: Colors.grey.shade400,
width: selectedColor == color ? 2.5 : 1.5,
),
),
),
),
),
const SizedBox(width: 4),
],
MouseRegion(
cursor: SystemMouseCursors.click,
child: InkWell(
onTap: onOpenFullPicker,
borderRadius: BorderRadius.circular(11),
child: Container(
width: 22,
height: 22,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.grey.shade400, width: 1.5),
),
child: const Icon(Icons.palette, size: 14, color: Colors.grey),
),
),
),
],
);
}
}

709
lib/widgets/ink_canvas.dart Normal file
View File

@@ -0,0 +1,709 @@
import 'dart:math';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
import 'package:uuid/uuid.dart';
import '../models/ink_point.dart';
import '../models/ink_stroke.dart';
import '../models/pen_tool.dart';
import '../models/pointer_device_kind.dart';
import '../models/pressure_curve.dart';
import '../utils/stroke_stabilizer.dart';
/// Controls whether the canvas accepts drawing input or passes events through.
enum InteractionMode { draw, navigate }
class InkCanvas extends StatefulWidget {
final List<InkStroke> strokes;
final void Function(InkStroke stroke)? onStrokeComplete;
final void Function(String strokeId, List<InkStroke> replacements)? onErase;
final PenTool tool;
final Color color;
final double strokeWidth;
final PressureCurve pressureCurve;
final StabilizationLevel stabilizationLevel;
final bool filled;
final InteractionMode interactionMode;
final Rect? viewportBounds;
const InkCanvas({
super.key,
required this.strokes,
this.onStrokeComplete,
this.onErase,
this.tool = PenTool.pen,
this.color = Colors.black,
this.strokeWidth = 2.0,
this.pressureCurve = PressureCurve.linear,
this.stabilizationLevel = StabilizationLevel.none,
this.filled = false,
this.interactionMode = InteractionMode.draw,
this.viewportBounds,
});
@override
State<InkCanvas> createState() => _InkCanvasState();
}
class _InkCanvasState extends State<InkCanvas> {
final List<InkPoint> _currentPoints = [];
bool _isDrawing = false;
PenTool? _activeTool;
StrokeStabilizer? _stabilizer;
/// Start point for shape tools.
InkPoint? _shapeStart;
/// Whether the active tool is a shape tool (needs only 2 points).
bool get _isShapeTool {
final t = _activeTool ?? widget.tool;
return t == PenTool.rectangle ||
t == PenTool.ellipse ||
t == PenTool.line ||
t == PenTool.arrow;
}
/// Whether the active tool is the text tool.
bool get _isTextTool {
return (_activeTool ?? widget.tool) == PenTool.text;
}
@override
void initState() {
super.initState();
_stabilizer = StrokeStabilizer(level: widget.stabilizationLevel);
}
@override
void didUpdateWidget(InkCanvas oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.stabilizationLevel != widget.stabilizationLevel) {
_stabilizer = StrokeStabilizer(level: widget.stabilizationLevel);
}
}
InputDeviceKind _mapKind(PointerDeviceKind kind) {
switch (kind) {
case PointerDeviceKind.touch:
return InputDeviceKind.touch;
case PointerDeviceKind.mouse:
return InputDeviceKind.mouse;
case PointerDeviceKind.stylus:
return InputDeviceKind.stylus;
case PointerDeviceKind.invertedStylus:
return InputDeviceKind.invertedStylus;
case PointerDeviceKind.trackpad:
return InputDeviceKind.trackpad;
case PointerDeviceKind.unknown:
return InputDeviceKind.unknown;
}
}
InkPoint _makePoint(PointerEvent event) {
return InkPoint(
x: event.localPosition.dx,
y: event.localPosition.dy,
pressure: event.pressure,
tilt: event is PointerMoveEvent ? event.tilt : 0.0,
timestamp: event.timeStamp.inMicroseconds,
pointerDeviceKind: _mapKind(event.kind),
);
}
void _handlePointerDown(PointerDownEvent event) {
if (event.kind == PointerDeviceKind.trackpad) return;
// In navigate mode, no drawing at all — pass all events through.
if (widget.interactionMode == InteractionMode.navigate) return;
// In draw mode: stylus and mouse draw, touch passes through for scrolling.
if (event.kind == PointerDeviceKind.touch) return;
_isDrawing = true;
_activeTool = widget.tool;
if (event.kind == PointerDeviceKind.invertedStylus) {
_activeTool = PenTool.eraser;
}
final point = _makePoint(event);
if (_activeTool == PenTool.eraser) {
_eraseAt(point);
} else if (_isTextTool) {
// Text tool: record position, handled on pointer up
_shapeStart = point;
} else if (_isShapeTool) {
// Shape tool: record start point
_shapeStart = point;
_stabilizer?.reset();
setState(() {
_currentPoints.clear();
_currentPoints.add(point);
});
} else {
// Freehand tools (pen, marker, highlighter)
_stabilizer?.reset();
final smoothed = _stabilizer?.filter(point) ?? point;
setState(() {
_currentPoints.clear();
_currentPoints.add(smoothed);
});
}
}
void _handlePointerMove(PointerMoveEvent event) {
if (!_isDrawing) return;
final point = _makePoint(event);
if (_activeTool == PenTool.eraser) {
_eraseAt(point);
} else if (_isTextTool) {
// No preview for text tool
return;
} else if (_isShapeTool) {
// Shape preview: keep only start + current
setState(() {
if (_currentPoints.length >= 2) {
_currentPoints[1] = point;
} else {
_currentPoints.add(point);
}
});
} else {
// Freehand
final smoothed = _stabilizer?.filter(point) ?? point;
setState(() {
_currentPoints.add(smoothed);
});
}
}
void _handlePointerUp(PointerUpEvent event) {
if (!_isDrawing) return;
_isDrawing = false;
final activeTool = _activeTool ?? widget.tool;
if (activeTool == PenTool.eraser) {
// Nothing to finalize
} else if (_isTextTool) {
if (_shapeStart != null) {
_showTextDialog(_shapeStart!);
}
} else if (_isShapeTool) {
// Shape: finalize with start + end points
if (_currentPoints.length >= 2) {
final stroke = InkStroke(
id: _generateId(),
points: List.from(_currentPoints),
tool: activeTool,
color: _getColorForTool(activeTool).toARGB32(),
strokeWidth: widget.strokeWidth,
createdAt: DateTime.now(),
filled: widget.filled,
);
widget.onStrokeComplete?.call(stroke);
}
} else if (_currentPoints.isNotEmpty) {
// Freehand
final stroke = InkStroke(
id: _generateId(),
points: List.from(_currentPoints),
tool: activeTool,
color: _getColorForTool(activeTool).toARGB32(),
strokeWidth: activeTool == PenTool.highlighter
? widget.strokeWidth * 3
: widget.strokeWidth,
createdAt: DateTime.now(),
);
widget.onStrokeComplete?.call(stroke);
}
setState(() {
_currentPoints.clear();
_shapeStart = null;
});
}
void _showTextDialog(InkPoint position) {
final controller = TextEditingController();
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Add Text'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(hintText: 'Enter text...'),
maxLines: null,
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
final text = controller.text.trim();
if (text.isNotEmpty) {
final stroke = InkStroke(
id: _generateId(),
points: [position],
tool: PenTool.text,
color: widget.color.toARGB32(),
strokeWidth: widget.strokeWidth,
createdAt: DateTime.now(),
textContent: text,
fontSize: widget.strokeWidth * 7,
);
widget.onStrokeComplete?.call(stroke);
}
Navigator.of(context).pop();
},
child: const Text('OK'),
),
],
);
},
);
}
void _eraseAt(InkPoint point) {
final eraserRadius = widget.strokeWidth * 3;
// Collect all (strokeId, replacements) pairs before invoking any callback,
// to avoid ConcurrentModificationError when the parent's onErase triggers
// a setState that mutates widget.strokes mid-iteration.
final toErase = <(String, List<InkStroke>)>[];
for (final stroke in widget.strokes) {
if (stroke.tool == PenTool.eraser) continue;
final erasedIndices = <int>{};
for (int i = 0; i < stroke.points.length; i++) {
final p = stroke.points[i];
final dx = p.x - point.x;
final dy = p.y - point.y;
if (dx * dx + dy * dy < eraserRadius * eraserRadius) {
erasedIndices.add(i);
}
}
if (erasedIndices.isEmpty) continue;
toErase.add((stroke.id, _splitStroke(stroke, erasedIndices)));
}
for (final (strokeId, replacements) in toErase) {
widget.onErase?.call(strokeId, replacements);
}
}
List<InkStroke> _splitStroke(InkStroke stroke, Set<int> erasedIndices) {
final segments = <List<InkPoint>>[];
List<InkPoint> currentSegment = [];
for (int i = 0; i < stroke.points.length; i++) {
if (erasedIndices.contains(i)) {
if (currentSegment.isNotEmpty) {
segments.add(currentSegment);
currentSegment = [];
}
} else {
currentSegment.add(stroke.points[i]);
}
}
if (currentSegment.isNotEmpty) {
segments.add(currentSegment);
}
final replacements = <InkStroke>[];
for (final segment in segments) {
if (segment.length >= 2) {
replacements.add(
InkStroke(
id: _generateId(),
points: segment,
tool: stroke.tool,
color: stroke.color,
strokeWidth: stroke.strokeWidth,
createdAt: stroke.createdAt,
filled: stroke.filled,
textContent: stroke.textContent,
fontSize: stroke.fontSize,
),
);
}
}
return replacements;
}
Color _getColorForTool(PenTool tool) {
switch (tool) {
case PenTool.marker:
return widget.color.withAlpha(77);
case PenTool.highlighter:
return const Color(0x80FFFF00);
case PenTool.pen:
case PenTool.eraser:
case PenTool.rectangle:
case PenTool.ellipse:
case PenTool.line:
case PenTool.arrow:
case PenTool.text:
return widget.color;
}
}
String _generateId() {
return const Uuid().v4();
}
@override
Widget build(BuildContext context) {
return Listener(
onPointerDown: _handlePointerDown,
onPointerMove: _handlePointerMove,
onPointerUp: _handlePointerUp,
child: CustomPaint(
painter: _InkPainter(
strokes: widget.strokes,
currentPoints: _currentPoints,
currentTool: _activeTool ?? widget.tool,
currentColor: _getColorForTool(_activeTool ?? widget.tool),
currentStrokeWidth:
(_activeTool ?? widget.tool) == PenTool.highlighter
? widget.strokeWidth * 3
: widget.strokeWidth,
pressureCurve: widget.pressureCurve,
filled: widget.filled,
viewportBounds: widget.viewportBounds,
),
size: Size.infinite,
),
);
}
}
class _InkPainter extends CustomPainter {
final List<InkStroke> strokes;
final List<InkPoint> currentPoints;
final PenTool currentTool;
final Color currentColor;
final double currentStrokeWidth;
final PressureCurve pressureCurve;
final bool filled;
final Rect? viewportBounds;
_InkPainter({
required this.strokes,
required this.currentPoints,
required this.currentTool,
required this.currentColor,
required this.currentStrokeWidth,
required this.pressureCurve,
required this.filled,
this.viewportBounds,
});
bool _strokeInViewport(InkStroke stroke, Rect viewport) {
if (stroke.points.isEmpty) return false;
double minX = double.infinity, minY = double.infinity;
double maxX = double.negativeInfinity, maxY = double.negativeInfinity;
for (final p in stroke.points) {
if (p.x < minX) minX = p.x;
if (p.y < minY) minY = p.y;
if (p.x > maxX) maxX = p.x;
if (p.y > maxY) maxY = p.y;
}
return viewport.overlaps(Rect.fromLTRB(minX, minY, maxX, maxY));
}
@override
void paint(Canvas canvas, Size size) {
for (final stroke in strokes) {
if (stroke.tool == PenTool.eraser) continue;
if (viewportBounds != null &&
!_strokeInViewport(stroke, viewportBounds!)) {
continue;
}
_drawStroke(
canvas,
stroke.points,
stroke.tool,
Color(stroke.color),
stroke.strokeWidth,
true,
stroke.filled,
stroke.textContent,
stroke.fontSize,
);
}
if (currentPoints.isNotEmpty && currentTool != PenTool.eraser) {
_drawStroke(
canvas,
currentPoints,
currentTool,
currentColor,
currentStrokeWidth,
false,
filled,
null,
14.0,
);
}
}
void _drawStroke(
Canvas canvas,
List<InkPoint> points,
PenTool tool,
Color color,
double strokeWidth,
bool isComplete,
bool strokeFilled,
String? textContent,
double fontSize,
) {
if (points.isEmpty) return;
switch (tool) {
case PenTool.pen:
case PenTool.marker:
case PenTool.highlighter:
case PenTool.eraser:
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
break;
case PenTool.rectangle:
if (points.length < 2) {
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
} else {
_drawRect(canvas, points, color, strokeWidth, strokeFilled);
}
break;
case PenTool.ellipse:
if (points.length < 2) {
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
} else {
_drawOval(canvas, points, color, strokeWidth, strokeFilled);
}
break;
case PenTool.line:
if (points.length < 2) {
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
} else {
_drawLine(canvas, points, color, strokeWidth);
}
break;
case PenTool.arrow:
if (points.length < 2) {
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
} else {
_drawArrow(canvas, points, color, strokeWidth);
}
break;
case PenTool.text:
if (textContent != null && textContent.isNotEmpty) {
_drawText(canvas, points, textContent, fontSize, color);
}
break;
}
}
void _drawFreehand(
Canvas canvas,
List<InkPoint> points,
PenTool tool,
Color color,
double strokeWidth,
bool isComplete,
) {
final pfPoints = points
.map(
(p) => pf.Point(
p.x,
p.y,
pressureCurve.apply(p.pressure).clamp(0.0, 1.0),
),
)
.toList();
final thinning = (tool == PenTool.marker || tool == PenTool.highlighter)
? 0.0
: 0.7;
final outlinePoints = pf.getStroke(
pfPoints,
size: strokeWidth,
thinning: thinning,
smoothing: 0.5,
streamline: 0.5,
taperStart: 0.0,
taperEnd: 0.0,
capStart: true,
capEnd: true,
simulatePressure: tool != PenTool.marker && tool != PenTool.highlighter,
isComplete: isComplete,
);
if (outlinePoints.isEmpty) return;
final path = Path();
path.moveTo(outlinePoints[0].x, outlinePoints[0].y);
for (int i = 1; i < outlinePoints.length; i++) {
path.lineTo(outlinePoints[i].x, outlinePoints[i].y);
}
path.close();
final paint = Paint()
..color = color
..style = PaintingStyle.fill
..isAntiAlias = true;
canvas.drawPath(path, paint);
}
void _drawRect(
Canvas canvas,
List<InkPoint> points,
Color color,
double strokeWidth,
bool strokeFilled,
) {
final rect = Rect.fromPoints(
Offset(points[0].x, points[0].y),
Offset(points[1].x, points[1].y),
);
final paint = Paint()
..color = color
..strokeWidth = strokeWidth
..isAntiAlias = true
..style = strokeFilled ? PaintingStyle.fill : PaintingStyle.stroke;
canvas.drawRect(rect, paint);
}
void _drawOval(
Canvas canvas,
List<InkPoint> points,
Color color,
double strokeWidth,
bool strokeFilled,
) {
final rect = Rect.fromPoints(
Offset(points[0].x, points[0].y),
Offset(points[1].x, points[1].y),
);
final paint = Paint()
..color = color
..strokeWidth = strokeWidth
..isAntiAlias = true
..style = strokeFilled ? PaintingStyle.fill : PaintingStyle.stroke;
canvas.drawOval(rect, paint);
}
void _drawLine(
Canvas canvas,
List<InkPoint> points,
Color color,
double strokeWidth,
) {
final paint = Paint()
..color = color
..strokeWidth = strokeWidth
..isAntiAlias = true
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
canvas.drawLine(
Offset(points[0].x, points[0].y),
Offset(points[1].x, points[1].y),
paint,
);
}
void _drawArrow(
Canvas canvas,
List<InkPoint> points,
Color color,
double strokeWidth,
) {
final p1 = Offset(points[0].x, points[0].y);
final p2 = Offset(points[1].x, points[1].y);
final paint = Paint()
..color = color
..strokeWidth = strokeWidth
..isAntiAlias = true
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
// Main line
canvas.drawLine(p1, p2, paint);
// Arrowhead
final dx = p2.dx - p1.dx;
final dy = p2.dy - p1.dy;
final angle = atan2(dy, dx);
final arrowLength = strokeWidth * 5;
const arrowAngle = pi / 6; // 30 degrees
final arrowP1 = Offset(
p2.dx - arrowLength * cos(angle - arrowAngle),
p2.dy - arrowLength * sin(angle - arrowAngle),
);
final arrowP2 = Offset(
p2.dx - arrowLength * cos(angle + arrowAngle),
p2.dy - arrowLength * sin(angle + arrowAngle),
);
canvas.drawLine(p2, arrowP1, paint);
canvas.drawLine(p2, arrowP2, paint);
}
void _drawText(
Canvas canvas,
List<InkPoint> points,
String text,
double fontSize,
Color color,
) {
final textPainter = TextPainter(
text: TextSpan(
text: text,
style: TextStyle(color: color, fontSize: fontSize),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(canvas, Offset(points[0].x, points[0].y));
}
@override
bool shouldRepaint(covariant _InkPainter oldDelegate) {
if (strokes.length != oldDelegate.strokes.length) return true;
if (currentPoints.length != oldDelegate.currentPoints.length) return true;
for (int i = 0; i < strokes.length; i++) {
final a = strokes[i], b = oldDelegate.strokes[i];
if (a.id != b.id ||
a.color != b.color ||
a.strokeWidth != b.strokeWidth ||
a.tool != b.tool) {
return true;
}
}
return currentTool != oldDelegate.currentTool;
}
}

View File

@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import '../services/thumbnail_service.dart';
/// Vertical sidebar showing page thumbnails for quick navigation.
///
/// Thumbnails are lazily generated and cached on disk. The current page is
/// highlighted with a blue border, and bookmarked pages show a colored dot.
class PageThumbnailSidebar extends StatefulWidget {
final String documentId;
final String filePath;
final int pageCount;
final int currentPage;
final ValueChanged<int> onPageTap;
final Set<int> bookmarkedPages;
const PageThumbnailSidebar({
super.key,
required this.documentId,
required this.filePath,
required this.pageCount,
required this.currentPage,
required this.onPageTap,
this.bookmarkedPages = const {},
});
@override
State<PageThumbnailSidebar> createState() => _PageThumbnailSidebarState();
}
class _PageThumbnailSidebarState extends State<PageThumbnailSidebar> {
/// Cached thumbnail image data keyed by page index.
final Map<int, ImageProvider> _cache = {};
/// Pages currently being generated (to avoid duplicate work).
final Set<int> _loading = {};
/// Pages that permanently failed thumbnail generation (null result or throw).
/// Skipped on subsequent rebuilds to avoid a retry storm.
final Set<int> _failed = {};
@override
void didUpdateWidget(PageThumbnailSidebar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.documentId != widget.documentId) {
_cache.clear();
_loading.clear();
_failed.clear();
}
}
Future<void> _loadThumbnail(int pageIndex) async {
if (_cache.containsKey(pageIndex) ||
_loading.contains(pageIndex) ||
_failed.contains(pageIndex)) {
return;
}
_loading.add(pageIndex);
try {
// Check disk cache first.
final cached = await ThumbnailService.getCached(
widget.documentId,
pageIndex,
);
if (cached != null && mounted) {
setState(() {
_cache[pageIndex] = FileImage(cached);
});
_loading.remove(pageIndex);
return;
}
// Generate from the PDF.
final bytes = await ThumbnailService.generate(
widget.filePath,
pageIndex,
maxWidth: 160,
);
if (bytes != null) {
await ThumbnailService.cacheThumbnail(
widget.documentId,
pageIndex,
bytes,
);
if (mounted) {
setState(() {
_cache[pageIndex] = MemoryImage(bytes);
});
}
} else {
// Null result means generation failed permanently for this page.
_failed.add(pageIndex);
}
} catch (_) {
// Any exception is treated as a permanent failure to avoid retry storms.
_failed.add(pageIndex);
} finally {
_loading.remove(pageIndex);
}
}
@override
Widget build(BuildContext context) {
return Container(
width: 120,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
border: Border(
right: BorderSide(color: Theme.of(context).dividerColor, width: 1),
),
),
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: widget.pageCount,
itemBuilder: (context, index) {
_loadThumbnail(index);
final isCurrentPage = index == widget.currentPage;
final isBookmarked = widget.bookmarkedPages.contains(index);
return GestureDetector(
onTap: () => widget.onPageTap(index),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
border: Border.all(
color: isCurrentPage
? Theme.of(context).colorScheme.primary
: Colors.grey.shade400,
width: isCurrentPage ? 2.5 : 1.0,
),
borderRadius: BorderRadius.circular(4),
),
child: Stack(
children: [
// Thumbnail image or placeholder.
AspectRatio(
aspectRatio: 8.5 / 11, // US Letter-ish ratio
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: _cache.containsKey(index)
? Image(image: _cache[index]!, fit: BoxFit.cover)
: Container(
color: Theme.of(
context,
).colorScheme.surfaceContainerLow,
child: Center(
child: Text(
'${index + 1}',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
),
),
),
),
// Page number overlay.
Positioned(
bottom: 2,
right: 2,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(3),
),
child: Text(
'${index + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
),
),
// Bookmark indicator.
if (isBookmarked)
Positioned(
top: 2,
left: 2,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
),
),
],
),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import '../models/ink_point.dart';
import '../models/ink_stroke.dart';
import '../models/pen_tool.dart';
import '../widgets/ink_canvas.dart';
/// Transparent overlay widget positioned on top of the PDF viewer.
///
/// Reuses the existing [InkCanvas] widget for ink rendering.
/// Coordinates are normalized to [0, 1] relative to the overlay size,
/// enabling correct mapping to PDF page coordinates during export.
class PdfAnnotationLayer extends StatefulWidget {
final List<InkStroke> strokes;
final void Function(InkStroke stroke)? onStrokeComplete;
final void Function(String strokeId, List<InkStroke> replacements)? onErase;
final PenTool tool;
final Color color;
final double strokeWidth;
final bool filled;
final InteractionMode interactionMode;
final int rotation;
const PdfAnnotationLayer({
super.key,
required this.strokes,
this.onStrokeComplete,
this.onErase,
this.tool = PenTool.pen,
this.color = Colors.black,
this.strokeWidth = 2.0,
this.filled = false,
this.interactionMode = InteractionMode.draw,
this.rotation = 0,
});
@override
State<PdfAnnotationLayer> createState() => _PdfAnnotationLayerState();
}
class _PdfAnnotationLayerState extends State<PdfAnnotationLayer> {
Size _canvasSize = Size.zero;
/// Applies inverse rotation to normalized coordinates for rendering.
/// Converts from stored (possibly rotated) coords back to display coords.
Offset _inverseRotate(double nx, double ny, int rotation) {
switch (rotation % 360) {
case 90:
return Offset(1.0 - ny, nx);
case 180:
return Offset(1.0 - nx, 1.0 - ny);
case 270:
return Offset(ny, 1.0 - nx);
default:
return Offset(nx, ny);
}
}
/// Applies forward rotation to normalized coordinates before storage.
/// Converts from display coords to the canonical rotated representation.
Offset _forwardRotate(double nx, double ny, int rotation) {
switch (rotation % 360) {
case 90:
return Offset(ny, 1.0 - nx);
case 180:
return Offset(1.0 - nx, 1.0 - ny);
case 270:
return Offset(1.0 - ny, nx);
default:
return Offset(nx, ny);
}
}
/// Scales a stroke's points from normalized [0, 1] coordinates to
/// the current canvas pixel coordinates for rendering.
/// Applies inverse rotation before scaling so strokes render correctly
/// on a rotated page.
List<InkStroke> get _scaledStrokes {
if (_canvasSize == Size.zero) return widget.strokes;
return widget.strokes.map((stroke) {
return InkStroke(
id: stroke.id,
points: stroke.points.map((pt) {
final rotated = _inverseRotate(pt.x, pt.y, widget.rotation);
return InkPoint(
x: rotated.dx * _canvasSize.width,
y: rotated.dy * _canvasSize.height,
pressure: pt.pressure,
tilt: pt.tilt,
timestamp: pt.timestamp,
pointerDeviceKind: pt.pointerDeviceKind,
);
}).toList(),
tool: stroke.tool,
color: stroke.color,
strokeWidth: stroke.strokeWidth,
createdAt: stroke.createdAt,
filled: stroke.filled,
textContent: stroke.textContent,
fontSize: stroke.fontSize,
);
}).toList();
}
/// Normalizes a stroke's points from canvas pixel coordinates to
/// [0, 1] relative to the overlay size.
/// Applies forward rotation before storage so the canonical representation
/// accounts for the current page rotation.
InkStroke _normalizeStroke(InkStroke stroke) {
if (_canvasSize == Size.zero) return stroke;
return InkStroke(
id: stroke.id,
points: stroke.points.map((pt) {
final nx = pt.x / _canvasSize.width;
final ny = pt.y / _canvasSize.height;
final rotated = _forwardRotate(nx, ny, widget.rotation);
return InkPoint(
x: rotated.dx,
y: rotated.dy,
pressure: pt.pressure,
tilt: pt.tilt,
timestamp: pt.timestamp,
pointerDeviceKind: pt.pointerDeviceKind,
);
}).toList(),
tool: stroke.tool,
color: stroke.color,
strokeWidth: stroke.strokeWidth,
createdAt: stroke.createdAt,
filled: stroke.filled,
textContent: stroke.textContent,
fontSize: stroke.fontSize,
);
}
void _onStrokeComplete(InkStroke stroke) {
widget.onStrokeComplete?.call(_normalizeStroke(stroke));
}
void _onErase(String strokeId, List<InkStroke> replacements) {
widget.onErase?.call(strokeId, replacements.map(_normalizeStroke).toList());
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
_canvasSize = Size(constraints.maxWidth, constraints.maxHeight);
return InkCanvas(
strokes: _scaledStrokes,
onStrokeComplete: _onStrokeComplete,
onErase: _onErase,
tool: widget.tool,
color: widget.color,
strokeWidth: widget.strokeWidth,
filled: widget.filled,
interactionMode: widget.interactionMode,
);
},
);
}
}