Fix bugs across app + server, optimize UI/UX, add Gitea CI
Bug fixes (Flutter): - Wrap multi-statement DB writes (insert/update/delete note, deleteDocument, deletePageData, OCR FTS merge, migrations) in transactions to prevent data loss on interruption and a read-modify-write FTS race. - Fix PdfDocument leaks on exception (try/finally dispose) and preserve image aspect ratio when stamping images onto PDF pages. - Guard file-picker against empty selection (was .single -> crash). - Fix eraser ConcurrentModificationError and unmodifiable-list crash on PDF pages; capture page synchronously on save to stop wrong-page data loss. - Fix Riverpod DB-not-ready races, broken pull-to-refresh, settings load race, and search N+1; transform stored annotations on PDF page rotation. - Normalize pen pressure for devices without a pressure range. - PPT: single source of truth for slide strokes so ink displays and exports. UI/UX: - Material 3 typography, theme-aware colors (dark-mode fixes), hover cursors and right-click/visible actions on desktop, keyboard shortcuts (undo/redo/ save/find), toolbar overflow handling, friendlier empty states, semantic OCR status badges, relative timestamps, 1-based page indicators, large-deck PPT navigation, and a scratchpad-scope label in split view. Server (optional backend): - Persist JWT secret (was per-process random), block path traversal in storage, fix CORS '*'+credentials, add OCR job ownership checks, last-writer-wins sync guard, constant-time login, and split out heavy OCR deps so the API/tests run without them. CI: Gitea workflows for format+analyze+test (Linux, system sqlite) and a Windows release build; pristine `flutter analyze`, all Flutter and server tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
55
lib/main.dart
Normal file
55
lib/main.dart
Normal 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
19
lib/models/bookmark.dart
Normal 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);
|
||||
}
|
||||
290
lib/models/bookmark.freezed.dart
Normal file
290
lib/models/bookmark.freezed.dart
Normal 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;
|
||||
}
|
||||
27
lib/models/bookmark.g.dart
Normal file
27
lib/models/bookmark.g.dart
Normal 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
21
lib/models/document.dart
Normal 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);
|
||||
}
|
||||
335
lib/models/document.freezed.dart
Normal file
335
lib/models/document.freezed.dart
Normal 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;
|
||||
}
|
||||
31
lib/models/document.g.dart
Normal file
31
lib/models/document.g.dart
Normal 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
21
lib/models/ink_point.dart
Normal 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);
|
||||
}
|
||||
291
lib/models/ink_point.freezed.dart
Normal file
291
lib/models/ink_point.freezed.dart
Normal 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;
|
||||
}
|
||||
42
lib/models/ink_point.g.dart
Normal file
42
lib/models/ink_point.g.dart
Normal 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',
|
||||
};
|
||||
25
lib/models/ink_stroke.dart
Normal file
25
lib/models/ink_stroke.dart
Normal 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);
|
||||
}
|
||||
363
lib/models/ink_stroke.freezed.dart
Normal file
363
lib/models/ink_stroke.freezed.dart
Normal 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;
|
||||
}
|
||||
47
lib/models/ink_stroke.g.dart
Normal file
47
lib/models/ink_stroke.g.dart
Normal 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
20
lib/models/note.dart
Normal 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);
|
||||
}
|
||||
297
lib/models/note.freezed.dart
Normal file
297
lib/models/note.freezed.dart
Normal 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
32
lib/models/note.g.dart
Normal 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
11
lib/models/pen_tool.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
enum PenTool {
|
||||
pen,
|
||||
marker,
|
||||
eraser,
|
||||
highlighter,
|
||||
rectangle,
|
||||
ellipse,
|
||||
line,
|
||||
arrow,
|
||||
text,
|
||||
}
|
||||
16
lib/models/pointer_device_kind.dart
Normal file
16
lib/models/pointer_device_kind.dart
Normal 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,
|
||||
}
|
||||
62
lib/models/pressure_curve.dart
Normal file
62
lib/models/pressure_curve.dart
Normal 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;
|
||||
}
|
||||
62
lib/providers/document_provider.dart
Normal file
62
lib/providers/document_provider.dart
Normal 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());
|
||||
}
|
||||
}
|
||||
68
lib/providers/note_provider.dart
Normal file
68
lib/providers/note_provider.dart
Normal 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);
|
||||
});
|
||||
43
lib/providers/ocr_provider.dart
Normal file
43
lib/providers/ocr_provider.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
93
lib/providers/search_provider.dart
Normal file
93
lib/providers/search_provider.dart
Normal 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;
|
||||
});
|
||||
146
lib/providers/settings_provider.dart
Normal file
146
lib/providers/settings_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
743
lib/screens/home_screen.dart
Normal file
743
lib/screens/home_screen.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
303
lib/screens/note_editor_screen.dart
Normal file
303
lib/screens/note_editor_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
981
lib/screens/pdf_annotator_screen.dart
Normal file
981
lib/screens/pdf_annotator_screen.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
141
lib/screens/pdf_text_search.dart
Normal file
141
lib/screens/pdf_text_search.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
528
lib/screens/ppt_annotator_screen.dart
Normal file
528
lib/screens/ppt_annotator_screen.dart
Normal 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());
|
||||
}
|
||||
}
|
||||
286
lib/screens/search_screen.dart
Normal file
286
lib/screens/search_screen.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
344
lib/screens/settings_screen.dart
Normal file
344
lib/screens/settings_screen.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
469
lib/screens/split_view_screen.dart
Normal file
469
lib/screens/split_view_screen.dart
Normal 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});
|
||||
}
|
||||
20
lib/services/camera_service.dart
Normal file
20
lib/services/camera_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
841
lib/services/database_service.dart
Normal file
841
lib/services/database_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
21
lib/services/ocr_engine.dart
Normal file
21
lib/services/ocr_engine.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
46
lib/services/ocr_service.dart
Normal file
46
lib/services/ocr_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
245
lib/services/pdf_service.dart
Normal file
245
lib/services/pdf_service.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
62
lib/services/pen_input_service.dart
Normal file
62
lib/services/pen_input_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
293
lib/services/pptx_service.dart
Normal file
293
lib/services/pptx_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
302
lib/services/stroke_rasterizer.dart
Normal file
302
lib/services/stroke_rasterizer.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
130
lib/services/thumbnail_service.dart
Normal file
130
lib/services/thumbnail_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
102
lib/services/undo_manager.dart
Normal file
102
lib/services/undo_manager.dart
Normal 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 [],
|
||||
});
|
||||
}
|
||||
70
lib/utils/stroke_stabilizer.dart
Normal file
70
lib/utils/stroke_stabilizer.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
433
lib/widgets/annotation_toolbar.dart
Normal file
433
lib/widgets/annotation_toolbar.dart
Normal file
@@ -0,0 +1,433 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
||||
|
||||
import '../models/pen_tool.dart';
|
||||
import '../models/pressure_curve.dart';
|
||||
import '../utils/stroke_stabilizer.dart';
|
||||
import '../widgets/ink_canvas.dart';
|
||||
import 'color_preset_bar.dart';
|
||||
|
||||
/// Shared annotation toolbar used by note editor, PDF annotator, and PPT annotator.
|
||||
class AnnotationToolbar extends StatelessWidget {
|
||||
final PenTool currentTool;
|
||||
final Color currentColor;
|
||||
final double currentStrokeWidth;
|
||||
final bool filled;
|
||||
final PressureCurveType pressureCurveType;
|
||||
final StabilizationLevel stabilizationLevel;
|
||||
final bool canUndo;
|
||||
final bool canRedo;
|
||||
final ValueChanged<PenTool> onToolChanged;
|
||||
final ValueChanged<Color> onColorChanged;
|
||||
final ValueChanged<double> onStrokeWidthChanged;
|
||||
final ValueChanged<bool> onFilledChanged;
|
||||
final ValueChanged<PressureCurveType> onPressureCurveChanged;
|
||||
final ValueChanged<StabilizationLevel> onStabilizationChanged;
|
||||
final VoidCallback? onUndo;
|
||||
final VoidCallback? onRedo;
|
||||
final VoidCallback? onPreviousPage;
|
||||
final VoidCallback? onNextPage;
|
||||
final String? pageInfo;
|
||||
final InteractionMode interactionMode;
|
||||
final ValueChanged<InteractionMode>? onInteractionModeChanged;
|
||||
final double? zoomLevel;
|
||||
final VoidCallback? onZoomIn;
|
||||
final VoidCallback? onZoomOut;
|
||||
final VoidCallback? onZoomFitWidth;
|
||||
final String? zoomLabel;
|
||||
|
||||
const AnnotationToolbar({
|
||||
super.key,
|
||||
required this.currentTool,
|
||||
required this.currentColor,
|
||||
required this.currentStrokeWidth,
|
||||
this.filled = false,
|
||||
required this.pressureCurveType,
|
||||
required this.stabilizationLevel,
|
||||
required this.canUndo,
|
||||
required this.canRedo,
|
||||
required this.onToolChanged,
|
||||
required this.onColorChanged,
|
||||
required this.onStrokeWidthChanged,
|
||||
required this.onFilledChanged,
|
||||
required this.onPressureCurveChanged,
|
||||
required this.onStabilizationChanged,
|
||||
this.onUndo,
|
||||
this.onRedo,
|
||||
this.onPreviousPage,
|
||||
this.onNextPage,
|
||||
this.pageInfo,
|
||||
this.interactionMode = InteractionMode.draw,
|
||||
this.onInteractionModeChanged,
|
||||
this.zoomLevel,
|
||||
this.onZoomIn,
|
||||
this.onZoomOut,
|
||||
this.onZoomFitWidth,
|
||||
this.zoomLabel,
|
||||
});
|
||||
|
||||
static const _toolDefinitions = [
|
||||
_ToolDef(PenTool.pen, Icons.edit, 'Pen'),
|
||||
_ToolDef(PenTool.marker, Icons.highlight, 'Marker'),
|
||||
_ToolDef(PenTool.highlighter, Icons.border_color, 'Highlighter'),
|
||||
_ToolDef(PenTool.eraser, Icons.auto_fix_normal, 'Eraser'),
|
||||
_ToolDef(PenTool.rectangle, Icons.rectangle_outlined, 'Rectangle'),
|
||||
_ToolDef(PenTool.ellipse, Icons.circle_outlined, 'Ellipse'),
|
||||
_ToolDef(PenTool.line, Icons.horizontal_rule, 'Line'),
|
||||
_ToolDef(PenTool.arrow, Icons.arrow_right_alt, 'Arrow'),
|
||||
_ToolDef(PenTool.text, Icons.text_fields, 'Text'),
|
||||
];
|
||||
|
||||
bool get _isShapeTool {
|
||||
return currentTool == PenTool.rectangle || currentTool == PenTool.ellipse;
|
||||
}
|
||||
|
||||
void _showColorPicker(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
Color pickerColor = currentColor;
|
||||
return AlertDialog(
|
||||
title: const Text('Pick a color'),
|
||||
content: SingleChildScrollView(
|
||||
child: ColorPicker(
|
||||
pickerColor: pickerColor,
|
||||
onColorChanged: (color) => pickerColor = color,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
onColorChanged(pickerColor);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Row 1: Mode toggle + Tools + color presets + stroke width + undo/redo
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
// Pen/Navigate mode toggle
|
||||
if (onInteractionModeChanged != null) ...[
|
||||
Tooltip(
|
||||
message: interactionMode == InteractionMode.draw
|
||||
? 'Drawing mode'
|
||||
: 'Navigate mode',
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
final newMode = interactionMode == InteractionMode.draw
|
||||
? InteractionMode.navigate
|
||||
: InteractionMode.draw;
|
||||
onInteractionModeChanged!(newMode);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: interactionMode == InteractionMode.draw
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
interactionMode == InteractionMode.draw
|
||||
? Icons.edit
|
||||
: Icons.pan_tool,
|
||||
size: 20,
|
||||
color: interactionMode == InteractionMode.draw
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
for (final def in _toolDefinitions) ...[
|
||||
_ToolButton(
|
||||
icon: def.icon,
|
||||
label: def.label,
|
||||
isSelected: currentTool == def.tool,
|
||||
onPressed: () => onToolChanged(def.tool),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
// Filled toggle for shape tools
|
||||
if (_isShapeTool) ...[
|
||||
const SizedBox(width: 4),
|
||||
Tooltip(
|
||||
message: filled ? 'Filled' : 'Outline',
|
||||
child: GestureDetector(
|
||||
onTap: () => onFilledChanged(!filled),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: filled
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: filled
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
filled ? Icons.square : Icons.square_outlined,
|
||||
size: 16,
|
||||
color: filled
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||
: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
ColorPresetBar(
|
||||
selectedColor: currentColor,
|
||||
onColorSelected: onColorChanged,
|
||||
onOpenFullPicker: () => _showColorPicker(context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Slider(
|
||||
value: currentStrokeWidth,
|
||||
min: 1.0,
|
||||
max: 20.0,
|
||||
divisions: 19,
|
||||
label: currentStrokeWidth.toStringAsFixed(1),
|
||||
onChanged: onStrokeWidthChanged,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.undo),
|
||||
tooltip: 'Undo',
|
||||
iconSize: 20,
|
||||
padding: const EdgeInsets.all(4),
|
||||
onPressed: canUndo ? onUndo : null,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.redo),
|
||||
tooltip: 'Redo',
|
||||
iconSize: 20,
|
||||
padding: const EdgeInsets.all(4),
|
||||
onPressed: canRedo ? onRedo : null,
|
||||
),
|
||||
// Page navigation (optional, for PDF/PPT)
|
||||
if (onPreviousPage != null) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.navigate_before),
|
||||
tooltip: 'Previous page',
|
||||
iconSize: 20,
|
||||
padding: const EdgeInsets.all(4),
|
||||
onPressed: onPreviousPage,
|
||||
),
|
||||
if (pageInfo != null)
|
||||
Text(pageInfo!, style: const TextStyle(fontSize: 12)),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.navigate_next),
|
||||
tooltip: 'Next page',
|
||||
iconSize: 20,
|
||||
padding: const EdgeInsets.all(4),
|
||||
onPressed: onNextPage,
|
||||
),
|
||||
],
|
||||
// Zoom controls (optional)
|
||||
if (onZoomIn != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.zoom_out),
|
||||
tooltip: 'Zoom out',
|
||||
iconSize: 20,
|
||||
padding: const EdgeInsets.all(4),
|
||||
onPressed: onZoomOut,
|
||||
),
|
||||
if (zoomLabel != null)
|
||||
Text(zoomLabel!, style: const TextStyle(fontSize: 11)),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.zoom_in),
|
||||
tooltip: 'Zoom in',
|
||||
iconSize: 20,
|
||||
padding: const EdgeInsets.all(4),
|
||||
onPressed: onZoomIn,
|
||||
),
|
||||
if (onZoomFitWidth != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.fit_screen),
|
||||
tooltip: 'Fit to width',
|
||||
iconSize: 20,
|
||||
padding: const EdgeInsets.all(4),
|
||||
onPressed: onZoomFitWidth,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
// Row 2: Pressure curve + stabilization selectors
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.touch_app, size: 14, color: Colors.grey),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'Pressure:',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_buildSegmentedButton<PressureCurveType>(
|
||||
context: context,
|
||||
options: const {
|
||||
PressureCurveType.linear: 'Lin',
|
||||
PressureCurveType.soft: 'Soft',
|
||||
PressureCurveType.hard: 'Hard',
|
||||
},
|
||||
selected: pressureCurveType,
|
||||
onChanged: onPressureCurveChanged,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const Icon(Icons.gesture, size: 14, color: Colors.grey),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'Smooth:',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_buildSegmentedButton<StabilizationLevel>(
|
||||
context: context,
|
||||
options: const {
|
||||
StabilizationLevel.none: 'Off',
|
||||
StabilizationLevel.light: 'Low',
|
||||
StabilizationLevel.medium: 'Med',
|
||||
StabilizationLevel.heavy: 'High',
|
||||
},
|
||||
selected: stabilizationLevel,
|
||||
onChanged: onStabilizationChanged,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSegmentedButton<T>({
|
||||
required BuildContext context,
|
||||
required Map<T, String> options,
|
||||
required T selected,
|
||||
required ValueChanged<T> onChanged,
|
||||
}) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Theme.of(context).colorScheme.outline),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: options.entries.map((entry) {
|
||||
final isSelected = entry.key == selected;
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(entry.key),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
child: Text(
|
||||
entry.value,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||
: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ToolDef {
|
||||
final PenTool tool;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
const _ToolDef(this.tool, this.icon, this.label);
|
||||
}
|
||||
|
||||
class _ToolButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _ToolButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: label,
|
||||
child: Material(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||
: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
75
lib/widgets/color_preset_bar.dart
Normal file
75
lib/widgets/color_preset_bar.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A row of preset color circles with a palette icon to open the full picker.
|
||||
class ColorPresetBar extends StatelessWidget {
|
||||
final Color selectedColor;
|
||||
final ValueChanged<Color> onColorSelected;
|
||||
final VoidCallback onOpenFullPicker;
|
||||
|
||||
const ColorPresetBar({
|
||||
super.key,
|
||||
required this.selectedColor,
|
||||
required this.onColorSelected,
|
||||
required this.onOpenFullPicker,
|
||||
});
|
||||
|
||||
static const List<Color> presetColors = [
|
||||
Colors.black,
|
||||
Color(0xFFE53935), // red
|
||||
Color(0xFF1E88E5), // blue
|
||||
Color(0xFF43A047), // green
|
||||
Color(0xFFFB8C00), // orange
|
||||
Color(0xFF8E24AA), // purple
|
||||
Color(0xFF6D4C41), // brown
|
||||
Colors.white,
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final color in presetColors) ...[
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: InkWell(
|
||||
onTap: () => onColorSelected(color),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
child: Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: selectedColor == color
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade400,
|
||||
width: selectedColor == color ? 2.5 : 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: InkWell(
|
||||
onTap: onOpenFullPicker,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
child: Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.grey.shade400, width: 1.5),
|
||||
),
|
||||
child: const Icon(Icons.palette, size: 14, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
709
lib/widgets/ink_canvas.dart
Normal file
709
lib/widgets/ink_canvas.dart
Normal file
@@ -0,0 +1,709 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/ink_point.dart';
|
||||
import '../models/ink_stroke.dart';
|
||||
import '../models/pen_tool.dart';
|
||||
import '../models/pointer_device_kind.dart';
|
||||
import '../models/pressure_curve.dart';
|
||||
import '../utils/stroke_stabilizer.dart';
|
||||
|
||||
/// Controls whether the canvas accepts drawing input or passes events through.
|
||||
enum InteractionMode { draw, navigate }
|
||||
|
||||
class InkCanvas extends StatefulWidget {
|
||||
final List<InkStroke> strokes;
|
||||
final void Function(InkStroke stroke)? onStrokeComplete;
|
||||
final void Function(String strokeId, List<InkStroke> replacements)? onErase;
|
||||
final PenTool tool;
|
||||
final Color color;
|
||||
final double strokeWidth;
|
||||
final PressureCurve pressureCurve;
|
||||
final StabilizationLevel stabilizationLevel;
|
||||
final bool filled;
|
||||
final InteractionMode interactionMode;
|
||||
final Rect? viewportBounds;
|
||||
|
||||
const InkCanvas({
|
||||
super.key,
|
||||
required this.strokes,
|
||||
this.onStrokeComplete,
|
||||
this.onErase,
|
||||
this.tool = PenTool.pen,
|
||||
this.color = Colors.black,
|
||||
this.strokeWidth = 2.0,
|
||||
this.pressureCurve = PressureCurve.linear,
|
||||
this.stabilizationLevel = StabilizationLevel.none,
|
||||
this.filled = false,
|
||||
this.interactionMode = InteractionMode.draw,
|
||||
this.viewportBounds,
|
||||
});
|
||||
|
||||
@override
|
||||
State<InkCanvas> createState() => _InkCanvasState();
|
||||
}
|
||||
|
||||
class _InkCanvasState extends State<InkCanvas> {
|
||||
final List<InkPoint> _currentPoints = [];
|
||||
bool _isDrawing = false;
|
||||
PenTool? _activeTool;
|
||||
StrokeStabilizer? _stabilizer;
|
||||
|
||||
/// Start point for shape tools.
|
||||
InkPoint? _shapeStart;
|
||||
|
||||
/// Whether the active tool is a shape tool (needs only 2 points).
|
||||
bool get _isShapeTool {
|
||||
final t = _activeTool ?? widget.tool;
|
||||
return t == PenTool.rectangle ||
|
||||
t == PenTool.ellipse ||
|
||||
t == PenTool.line ||
|
||||
t == PenTool.arrow;
|
||||
}
|
||||
|
||||
/// Whether the active tool is the text tool.
|
||||
bool get _isTextTool {
|
||||
return (_activeTool ?? widget.tool) == PenTool.text;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_stabilizer = StrokeStabilizer(level: widget.stabilizationLevel);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(InkCanvas oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.stabilizationLevel != widget.stabilizationLevel) {
|
||||
_stabilizer = StrokeStabilizer(level: widget.stabilizationLevel);
|
||||
}
|
||||
}
|
||||
|
||||
InputDeviceKind _mapKind(PointerDeviceKind kind) {
|
||||
switch (kind) {
|
||||
case PointerDeviceKind.touch:
|
||||
return InputDeviceKind.touch;
|
||||
case PointerDeviceKind.mouse:
|
||||
return InputDeviceKind.mouse;
|
||||
case PointerDeviceKind.stylus:
|
||||
return InputDeviceKind.stylus;
|
||||
case PointerDeviceKind.invertedStylus:
|
||||
return InputDeviceKind.invertedStylus;
|
||||
case PointerDeviceKind.trackpad:
|
||||
return InputDeviceKind.trackpad;
|
||||
case PointerDeviceKind.unknown:
|
||||
return InputDeviceKind.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
InkPoint _makePoint(PointerEvent event) {
|
||||
return InkPoint(
|
||||
x: event.localPosition.dx,
|
||||
y: event.localPosition.dy,
|
||||
pressure: event.pressure,
|
||||
tilt: event is PointerMoveEvent ? event.tilt : 0.0,
|
||||
timestamp: event.timeStamp.inMicroseconds,
|
||||
pointerDeviceKind: _mapKind(event.kind),
|
||||
);
|
||||
}
|
||||
|
||||
void _handlePointerDown(PointerDownEvent event) {
|
||||
if (event.kind == PointerDeviceKind.trackpad) return;
|
||||
|
||||
// In navigate mode, no drawing at all — pass all events through.
|
||||
if (widget.interactionMode == InteractionMode.navigate) return;
|
||||
|
||||
// In draw mode: stylus and mouse draw, touch passes through for scrolling.
|
||||
if (event.kind == PointerDeviceKind.touch) return;
|
||||
|
||||
_isDrawing = true;
|
||||
_activeTool = widget.tool;
|
||||
|
||||
if (event.kind == PointerDeviceKind.invertedStylus) {
|
||||
_activeTool = PenTool.eraser;
|
||||
}
|
||||
|
||||
final point = _makePoint(event);
|
||||
|
||||
if (_activeTool == PenTool.eraser) {
|
||||
_eraseAt(point);
|
||||
} else if (_isTextTool) {
|
||||
// Text tool: record position, handled on pointer up
|
||||
_shapeStart = point;
|
||||
} else if (_isShapeTool) {
|
||||
// Shape tool: record start point
|
||||
_shapeStart = point;
|
||||
_stabilizer?.reset();
|
||||
setState(() {
|
||||
_currentPoints.clear();
|
||||
_currentPoints.add(point);
|
||||
});
|
||||
} else {
|
||||
// Freehand tools (pen, marker, highlighter)
|
||||
_stabilizer?.reset();
|
||||
final smoothed = _stabilizer?.filter(point) ?? point;
|
||||
setState(() {
|
||||
_currentPoints.clear();
|
||||
_currentPoints.add(smoothed);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePointerMove(PointerMoveEvent event) {
|
||||
if (!_isDrawing) return;
|
||||
|
||||
final point = _makePoint(event);
|
||||
|
||||
if (_activeTool == PenTool.eraser) {
|
||||
_eraseAt(point);
|
||||
} else if (_isTextTool) {
|
||||
// No preview for text tool
|
||||
return;
|
||||
} else if (_isShapeTool) {
|
||||
// Shape preview: keep only start + current
|
||||
setState(() {
|
||||
if (_currentPoints.length >= 2) {
|
||||
_currentPoints[1] = point;
|
||||
} else {
|
||||
_currentPoints.add(point);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Freehand
|
||||
final smoothed = _stabilizer?.filter(point) ?? point;
|
||||
setState(() {
|
||||
_currentPoints.add(smoothed);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePointerUp(PointerUpEvent event) {
|
||||
if (!_isDrawing) return;
|
||||
_isDrawing = false;
|
||||
|
||||
final activeTool = _activeTool ?? widget.tool;
|
||||
|
||||
if (activeTool == PenTool.eraser) {
|
||||
// Nothing to finalize
|
||||
} else if (_isTextTool) {
|
||||
if (_shapeStart != null) {
|
||||
_showTextDialog(_shapeStart!);
|
||||
}
|
||||
} else if (_isShapeTool) {
|
||||
// Shape: finalize with start + end points
|
||||
if (_currentPoints.length >= 2) {
|
||||
final stroke = InkStroke(
|
||||
id: _generateId(),
|
||||
points: List.from(_currentPoints),
|
||||
tool: activeTool,
|
||||
color: _getColorForTool(activeTool).toARGB32(),
|
||||
strokeWidth: widget.strokeWidth,
|
||||
createdAt: DateTime.now(),
|
||||
filled: widget.filled,
|
||||
);
|
||||
widget.onStrokeComplete?.call(stroke);
|
||||
}
|
||||
} else if (_currentPoints.isNotEmpty) {
|
||||
// Freehand
|
||||
final stroke = InkStroke(
|
||||
id: _generateId(),
|
||||
points: List.from(_currentPoints),
|
||||
tool: activeTool,
|
||||
color: _getColorForTool(activeTool).toARGB32(),
|
||||
strokeWidth: activeTool == PenTool.highlighter
|
||||
? widget.strokeWidth * 3
|
||||
: widget.strokeWidth,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
widget.onStrokeComplete?.call(stroke);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_currentPoints.clear();
|
||||
_shapeStart = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _showTextDialog(InkPoint position) {
|
||||
final controller = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Add Text'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(hintText: 'Enter text...'),
|
||||
maxLines: null,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final text = controller.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
final stroke = InkStroke(
|
||||
id: _generateId(),
|
||||
points: [position],
|
||||
tool: PenTool.text,
|
||||
color: widget.color.toARGB32(),
|
||||
strokeWidth: widget.strokeWidth,
|
||||
createdAt: DateTime.now(),
|
||||
textContent: text,
|
||||
fontSize: widget.strokeWidth * 7,
|
||||
);
|
||||
widget.onStrokeComplete?.call(stroke);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _eraseAt(InkPoint point) {
|
||||
final eraserRadius = widget.strokeWidth * 3;
|
||||
|
||||
// Collect all (strokeId, replacements) pairs before invoking any callback,
|
||||
// to avoid ConcurrentModificationError when the parent's onErase triggers
|
||||
// a setState that mutates widget.strokes mid-iteration.
|
||||
final toErase = <(String, List<InkStroke>)>[];
|
||||
|
||||
for (final stroke in widget.strokes) {
|
||||
if (stroke.tool == PenTool.eraser) continue;
|
||||
|
||||
final erasedIndices = <int>{};
|
||||
for (int i = 0; i < stroke.points.length; i++) {
|
||||
final p = stroke.points[i];
|
||||
final dx = p.x - point.x;
|
||||
final dy = p.y - point.y;
|
||||
if (dx * dx + dy * dy < eraserRadius * eraserRadius) {
|
||||
erasedIndices.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (erasedIndices.isEmpty) continue;
|
||||
|
||||
toErase.add((stroke.id, _splitStroke(stroke, erasedIndices)));
|
||||
}
|
||||
|
||||
for (final (strokeId, replacements) in toErase) {
|
||||
widget.onErase?.call(strokeId, replacements);
|
||||
}
|
||||
}
|
||||
|
||||
List<InkStroke> _splitStroke(InkStroke stroke, Set<int> erasedIndices) {
|
||||
final segments = <List<InkPoint>>[];
|
||||
List<InkPoint> currentSegment = [];
|
||||
|
||||
for (int i = 0; i < stroke.points.length; i++) {
|
||||
if (erasedIndices.contains(i)) {
|
||||
if (currentSegment.isNotEmpty) {
|
||||
segments.add(currentSegment);
|
||||
currentSegment = [];
|
||||
}
|
||||
} else {
|
||||
currentSegment.add(stroke.points[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSegment.isNotEmpty) {
|
||||
segments.add(currentSegment);
|
||||
}
|
||||
|
||||
final replacements = <InkStroke>[];
|
||||
for (final segment in segments) {
|
||||
if (segment.length >= 2) {
|
||||
replacements.add(
|
||||
InkStroke(
|
||||
id: _generateId(),
|
||||
points: segment,
|
||||
tool: stroke.tool,
|
||||
color: stroke.color,
|
||||
strokeWidth: stroke.strokeWidth,
|
||||
createdAt: stroke.createdAt,
|
||||
filled: stroke.filled,
|
||||
textContent: stroke.textContent,
|
||||
fontSize: stroke.fontSize,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return replacements;
|
||||
}
|
||||
|
||||
Color _getColorForTool(PenTool tool) {
|
||||
switch (tool) {
|
||||
case PenTool.marker:
|
||||
return widget.color.withAlpha(77);
|
||||
case PenTool.highlighter:
|
||||
return const Color(0x80FFFF00);
|
||||
case PenTool.pen:
|
||||
case PenTool.eraser:
|
||||
case PenTool.rectangle:
|
||||
case PenTool.ellipse:
|
||||
case PenTool.line:
|
||||
case PenTool.arrow:
|
||||
case PenTool.text:
|
||||
return widget.color;
|
||||
}
|
||||
}
|
||||
|
||||
String _generateId() {
|
||||
return const Uuid().v4();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Listener(
|
||||
onPointerDown: _handlePointerDown,
|
||||
onPointerMove: _handlePointerMove,
|
||||
onPointerUp: _handlePointerUp,
|
||||
child: CustomPaint(
|
||||
painter: _InkPainter(
|
||||
strokes: widget.strokes,
|
||||
currentPoints: _currentPoints,
|
||||
currentTool: _activeTool ?? widget.tool,
|
||||
currentColor: _getColorForTool(_activeTool ?? widget.tool),
|
||||
currentStrokeWidth:
|
||||
(_activeTool ?? widget.tool) == PenTool.highlighter
|
||||
? widget.strokeWidth * 3
|
||||
: widget.strokeWidth,
|
||||
pressureCurve: widget.pressureCurve,
|
||||
filled: widget.filled,
|
||||
viewportBounds: widget.viewportBounds,
|
||||
),
|
||||
size: Size.infinite,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InkPainter extends CustomPainter {
|
||||
final List<InkStroke> strokes;
|
||||
final List<InkPoint> currentPoints;
|
||||
final PenTool currentTool;
|
||||
final Color currentColor;
|
||||
final double currentStrokeWidth;
|
||||
final PressureCurve pressureCurve;
|
||||
final bool filled;
|
||||
final Rect? viewportBounds;
|
||||
|
||||
_InkPainter({
|
||||
required this.strokes,
|
||||
required this.currentPoints,
|
||||
required this.currentTool,
|
||||
required this.currentColor,
|
||||
required this.currentStrokeWidth,
|
||||
required this.pressureCurve,
|
||||
required this.filled,
|
||||
this.viewportBounds,
|
||||
});
|
||||
|
||||
bool _strokeInViewport(InkStroke stroke, Rect viewport) {
|
||||
if (stroke.points.isEmpty) return false;
|
||||
double minX = double.infinity, minY = double.infinity;
|
||||
double maxX = double.negativeInfinity, maxY = double.negativeInfinity;
|
||||
for (final p in stroke.points) {
|
||||
if (p.x < minX) minX = p.x;
|
||||
if (p.y < minY) minY = p.y;
|
||||
if (p.x > maxX) maxX = p.x;
|
||||
if (p.y > maxY) maxY = p.y;
|
||||
}
|
||||
return viewport.overlaps(Rect.fromLTRB(minX, minY, maxX, maxY));
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
for (final stroke in strokes) {
|
||||
if (stroke.tool == PenTool.eraser) continue;
|
||||
if (viewportBounds != null &&
|
||||
!_strokeInViewport(stroke, viewportBounds!)) {
|
||||
continue;
|
||||
}
|
||||
_drawStroke(
|
||||
canvas,
|
||||
stroke.points,
|
||||
stroke.tool,
|
||||
Color(stroke.color),
|
||||
stroke.strokeWidth,
|
||||
true,
|
||||
stroke.filled,
|
||||
stroke.textContent,
|
||||
stroke.fontSize,
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPoints.isNotEmpty && currentTool != PenTool.eraser) {
|
||||
_drawStroke(
|
||||
canvas,
|
||||
currentPoints,
|
||||
currentTool,
|
||||
currentColor,
|
||||
currentStrokeWidth,
|
||||
false,
|
||||
filled,
|
||||
null,
|
||||
14.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _drawStroke(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
PenTool tool,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
bool isComplete,
|
||||
bool strokeFilled,
|
||||
String? textContent,
|
||||
double fontSize,
|
||||
) {
|
||||
if (points.isEmpty) return;
|
||||
|
||||
switch (tool) {
|
||||
case PenTool.pen:
|
||||
case PenTool.marker:
|
||||
case PenTool.highlighter:
|
||||
case PenTool.eraser:
|
||||
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
||||
break;
|
||||
case PenTool.rectangle:
|
||||
if (points.length < 2) {
|
||||
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
||||
} else {
|
||||
_drawRect(canvas, points, color, strokeWidth, strokeFilled);
|
||||
}
|
||||
break;
|
||||
case PenTool.ellipse:
|
||||
if (points.length < 2) {
|
||||
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
||||
} else {
|
||||
_drawOval(canvas, points, color, strokeWidth, strokeFilled);
|
||||
}
|
||||
break;
|
||||
case PenTool.line:
|
||||
if (points.length < 2) {
|
||||
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
||||
} else {
|
||||
_drawLine(canvas, points, color, strokeWidth);
|
||||
}
|
||||
break;
|
||||
case PenTool.arrow:
|
||||
if (points.length < 2) {
|
||||
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
||||
} else {
|
||||
_drawArrow(canvas, points, color, strokeWidth);
|
||||
}
|
||||
break;
|
||||
case PenTool.text:
|
||||
if (textContent != null && textContent.isNotEmpty) {
|
||||
_drawText(canvas, points, textContent, fontSize, color);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _drawFreehand(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
PenTool tool,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
bool isComplete,
|
||||
) {
|
||||
final pfPoints = points
|
||||
.map(
|
||||
(p) => pf.Point(
|
||||
p.x,
|
||||
p.y,
|
||||
pressureCurve.apply(p.pressure).clamp(0.0, 1.0),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final thinning = (tool == PenTool.marker || tool == PenTool.highlighter)
|
||||
? 0.0
|
||||
: 0.7;
|
||||
|
||||
final outlinePoints = pf.getStroke(
|
||||
pfPoints,
|
||||
size: strokeWidth,
|
||||
thinning: thinning,
|
||||
smoothing: 0.5,
|
||||
streamline: 0.5,
|
||||
taperStart: 0.0,
|
||||
taperEnd: 0.0,
|
||||
capStart: true,
|
||||
capEnd: true,
|
||||
simulatePressure: tool != PenTool.marker && tool != PenTool.highlighter,
|
||||
isComplete: isComplete,
|
||||
);
|
||||
|
||||
if (outlinePoints.isEmpty) return;
|
||||
|
||||
final path = Path();
|
||||
path.moveTo(outlinePoints[0].x, outlinePoints[0].y);
|
||||
|
||||
for (int i = 1; i < outlinePoints.length; i++) {
|
||||
path.lineTo(outlinePoints[i].x, outlinePoints[i].y);
|
||||
}
|
||||
path.close();
|
||||
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.fill
|
||||
..isAntiAlias = true;
|
||||
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
void _drawRect(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
bool strokeFilled,
|
||||
) {
|
||||
final rect = Rect.fromPoints(
|
||||
Offset(points[0].x, points[0].y),
|
||||
Offset(points[1].x, points[1].y),
|
||||
);
|
||||
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = strokeWidth
|
||||
..isAntiAlias = true
|
||||
..style = strokeFilled ? PaintingStyle.fill : PaintingStyle.stroke;
|
||||
|
||||
canvas.drawRect(rect, paint);
|
||||
}
|
||||
|
||||
void _drawOval(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
bool strokeFilled,
|
||||
) {
|
||||
final rect = Rect.fromPoints(
|
||||
Offset(points[0].x, points[0].y),
|
||||
Offset(points[1].x, points[1].y),
|
||||
);
|
||||
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = strokeWidth
|
||||
..isAntiAlias = true
|
||||
..style = strokeFilled ? PaintingStyle.fill : PaintingStyle.stroke;
|
||||
|
||||
canvas.drawOval(rect, paint);
|
||||
}
|
||||
|
||||
void _drawLine(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = strokeWidth
|
||||
..isAntiAlias = true
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
canvas.drawLine(
|
||||
Offset(points[0].x, points[0].y),
|
||||
Offset(points[1].x, points[1].y),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
|
||||
void _drawArrow(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
Color color,
|
||||
double strokeWidth,
|
||||
) {
|
||||
final p1 = Offset(points[0].x, points[0].y);
|
||||
final p2 = Offset(points[1].x, points[1].y);
|
||||
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = strokeWidth
|
||||
..isAntiAlias = true
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
// Main line
|
||||
canvas.drawLine(p1, p2, paint);
|
||||
|
||||
// Arrowhead
|
||||
final dx = p2.dx - p1.dx;
|
||||
final dy = p2.dy - p1.dy;
|
||||
final angle = atan2(dy, dx);
|
||||
final arrowLength = strokeWidth * 5;
|
||||
const arrowAngle = pi / 6; // 30 degrees
|
||||
|
||||
final arrowP1 = Offset(
|
||||
p2.dx - arrowLength * cos(angle - arrowAngle),
|
||||
p2.dy - arrowLength * sin(angle - arrowAngle),
|
||||
);
|
||||
final arrowP2 = Offset(
|
||||
p2.dx - arrowLength * cos(angle + arrowAngle),
|
||||
p2.dy - arrowLength * sin(angle + arrowAngle),
|
||||
);
|
||||
|
||||
canvas.drawLine(p2, arrowP1, paint);
|
||||
canvas.drawLine(p2, arrowP2, paint);
|
||||
}
|
||||
|
||||
void _drawText(
|
||||
Canvas canvas,
|
||||
List<InkPoint> points,
|
||||
String text,
|
||||
double fontSize,
|
||||
Color color,
|
||||
) {
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(color: color, fontSize: fontSize),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
textPainter.layout();
|
||||
textPainter.paint(canvas, Offset(points[0].x, points[0].y));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _InkPainter oldDelegate) {
|
||||
if (strokes.length != oldDelegate.strokes.length) return true;
|
||||
if (currentPoints.length != oldDelegate.currentPoints.length) return true;
|
||||
for (int i = 0; i < strokes.length; i++) {
|
||||
final a = strokes[i], b = oldDelegate.strokes[i];
|
||||
if (a.id != b.id ||
|
||||
a.color != b.color ||
|
||||
a.strokeWidth != b.strokeWidth ||
|
||||
a.tool != b.tool) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return currentTool != oldDelegate.currentTool;
|
||||
}
|
||||
}
|
||||
206
lib/widgets/page_thumbnail_sidebar.dart
Normal file
206
lib/widgets/page_thumbnail_sidebar.dart
Normal file
@@ -0,0 +1,206 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/thumbnail_service.dart';
|
||||
|
||||
/// Vertical sidebar showing page thumbnails for quick navigation.
|
||||
///
|
||||
/// Thumbnails are lazily generated and cached on disk. The current page is
|
||||
/// highlighted with a blue border, and bookmarked pages show a colored dot.
|
||||
class PageThumbnailSidebar extends StatefulWidget {
|
||||
final String documentId;
|
||||
final String filePath;
|
||||
final int pageCount;
|
||||
final int currentPage;
|
||||
final ValueChanged<int> onPageTap;
|
||||
final Set<int> bookmarkedPages;
|
||||
|
||||
const PageThumbnailSidebar({
|
||||
super.key,
|
||||
required this.documentId,
|
||||
required this.filePath,
|
||||
required this.pageCount,
|
||||
required this.currentPage,
|
||||
required this.onPageTap,
|
||||
this.bookmarkedPages = const {},
|
||||
});
|
||||
|
||||
@override
|
||||
State<PageThumbnailSidebar> createState() => _PageThumbnailSidebarState();
|
||||
}
|
||||
|
||||
class _PageThumbnailSidebarState extends State<PageThumbnailSidebar> {
|
||||
/// Cached thumbnail image data keyed by page index.
|
||||
final Map<int, ImageProvider> _cache = {};
|
||||
|
||||
/// Pages currently being generated (to avoid duplicate work).
|
||||
final Set<int> _loading = {};
|
||||
|
||||
/// Pages that permanently failed thumbnail generation (null result or throw).
|
||||
/// Skipped on subsequent rebuilds to avoid a retry storm.
|
||||
final Set<int> _failed = {};
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PageThumbnailSidebar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.documentId != widget.documentId) {
|
||||
_cache.clear();
|
||||
_loading.clear();
|
||||
_failed.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadThumbnail(int pageIndex) async {
|
||||
if (_cache.containsKey(pageIndex) ||
|
||||
_loading.contains(pageIndex) ||
|
||||
_failed.contains(pageIndex)) {
|
||||
return;
|
||||
}
|
||||
_loading.add(pageIndex);
|
||||
|
||||
try {
|
||||
// Check disk cache first.
|
||||
final cached = await ThumbnailService.getCached(
|
||||
widget.documentId,
|
||||
pageIndex,
|
||||
);
|
||||
if (cached != null && mounted) {
|
||||
setState(() {
|
||||
_cache[pageIndex] = FileImage(cached);
|
||||
});
|
||||
_loading.remove(pageIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate from the PDF.
|
||||
final bytes = await ThumbnailService.generate(
|
||||
widget.filePath,
|
||||
pageIndex,
|
||||
maxWidth: 160,
|
||||
);
|
||||
if (bytes != null) {
|
||||
await ThumbnailService.cacheThumbnail(
|
||||
widget.documentId,
|
||||
pageIndex,
|
||||
bytes,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_cache[pageIndex] = MemoryImage(bytes);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Null result means generation failed permanently for this page.
|
||||
_failed.add(pageIndex);
|
||||
}
|
||||
} catch (_) {
|
||||
// Any exception is treated as a permanent failure to avoid retry storms.
|
||||
_failed.add(pageIndex);
|
||||
} finally {
|
||||
_loading.remove(pageIndex);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
border: Border(
|
||||
right: BorderSide(color: Theme.of(context).dividerColor, width: 1),
|
||||
),
|
||||
),
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: widget.pageCount,
|
||||
itemBuilder: (context, index) {
|
||||
_loadThumbnail(index);
|
||||
final isCurrentPage = index == widget.currentPage;
|
||||
final isBookmarked = widget.bookmarkedPages.contains(index);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onPageTap(index),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isCurrentPage
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade400,
|
||||
width: isCurrentPage ? 2.5 : 1.0,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Thumbnail image or placeholder.
|
||||
AspectRatio(
|
||||
aspectRatio: 8.5 / 11, // US Letter-ish ratio
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: _cache.containsKey(index)
|
||||
? Image(image: _cache[index]!, fit: BoxFit.cover)
|
||||
: Container(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerLow,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Page number overlay.
|
||||
Positioned(
|
||||
bottom: 2,
|
||||
right: 2,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bookmark indicator.
|
||||
if (isBookmarked)
|
||||
Positioned(
|
||||
top: 2,
|
||||
left: 2,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
162
lib/widgets/pdf_annotation_layer.dart
Normal file
162
lib/widgets/pdf_annotation_layer.dart
Normal file
@@ -0,0 +1,162 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/ink_point.dart';
|
||||
import '../models/ink_stroke.dart';
|
||||
import '../models/pen_tool.dart';
|
||||
import '../widgets/ink_canvas.dart';
|
||||
|
||||
/// Transparent overlay widget positioned on top of the PDF viewer.
|
||||
///
|
||||
/// Reuses the existing [InkCanvas] widget for ink rendering.
|
||||
/// Coordinates are normalized to [0, 1] relative to the overlay size,
|
||||
/// enabling correct mapping to PDF page coordinates during export.
|
||||
class PdfAnnotationLayer extends StatefulWidget {
|
||||
final List<InkStroke> strokes;
|
||||
final void Function(InkStroke stroke)? onStrokeComplete;
|
||||
final void Function(String strokeId, List<InkStroke> replacements)? onErase;
|
||||
final PenTool tool;
|
||||
final Color color;
|
||||
final double strokeWidth;
|
||||
final bool filled;
|
||||
final InteractionMode interactionMode;
|
||||
final int rotation;
|
||||
|
||||
const PdfAnnotationLayer({
|
||||
super.key,
|
||||
required this.strokes,
|
||||
this.onStrokeComplete,
|
||||
this.onErase,
|
||||
this.tool = PenTool.pen,
|
||||
this.color = Colors.black,
|
||||
this.strokeWidth = 2.0,
|
||||
this.filled = false,
|
||||
this.interactionMode = InteractionMode.draw,
|
||||
this.rotation = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PdfAnnotationLayer> createState() => _PdfAnnotationLayerState();
|
||||
}
|
||||
|
||||
class _PdfAnnotationLayerState extends State<PdfAnnotationLayer> {
|
||||
Size _canvasSize = Size.zero;
|
||||
|
||||
/// Applies inverse rotation to normalized coordinates for rendering.
|
||||
/// Converts from stored (possibly rotated) coords back to display coords.
|
||||
Offset _inverseRotate(double nx, double ny, int rotation) {
|
||||
switch (rotation % 360) {
|
||||
case 90:
|
||||
return Offset(1.0 - ny, nx);
|
||||
case 180:
|
||||
return Offset(1.0 - nx, 1.0 - ny);
|
||||
case 270:
|
||||
return Offset(ny, 1.0 - nx);
|
||||
default:
|
||||
return Offset(nx, ny);
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies forward rotation to normalized coordinates before storage.
|
||||
/// Converts from display coords to the canonical rotated representation.
|
||||
Offset _forwardRotate(double nx, double ny, int rotation) {
|
||||
switch (rotation % 360) {
|
||||
case 90:
|
||||
return Offset(ny, 1.0 - nx);
|
||||
case 180:
|
||||
return Offset(1.0 - nx, 1.0 - ny);
|
||||
case 270:
|
||||
return Offset(1.0 - ny, nx);
|
||||
default:
|
||||
return Offset(nx, ny);
|
||||
}
|
||||
}
|
||||
|
||||
/// Scales a stroke's points from normalized [0, 1] coordinates to
|
||||
/// the current canvas pixel coordinates for rendering.
|
||||
/// Applies inverse rotation before scaling so strokes render correctly
|
||||
/// on a rotated page.
|
||||
List<InkStroke> get _scaledStrokes {
|
||||
if (_canvasSize == Size.zero) return widget.strokes;
|
||||
return widget.strokes.map((stroke) {
|
||||
return InkStroke(
|
||||
id: stroke.id,
|
||||
points: stroke.points.map((pt) {
|
||||
final rotated = _inverseRotate(pt.x, pt.y, widget.rotation);
|
||||
return InkPoint(
|
||||
x: rotated.dx * _canvasSize.width,
|
||||
y: rotated.dy * _canvasSize.height,
|
||||
pressure: pt.pressure,
|
||||
tilt: pt.tilt,
|
||||
timestamp: pt.timestamp,
|
||||
pointerDeviceKind: pt.pointerDeviceKind,
|
||||
);
|
||||
}).toList(),
|
||||
tool: stroke.tool,
|
||||
color: stroke.color,
|
||||
strokeWidth: stroke.strokeWidth,
|
||||
createdAt: stroke.createdAt,
|
||||
filled: stroke.filled,
|
||||
textContent: stroke.textContent,
|
||||
fontSize: stroke.fontSize,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Normalizes a stroke's points from canvas pixel coordinates to
|
||||
/// [0, 1] relative to the overlay size.
|
||||
/// Applies forward rotation before storage so the canonical representation
|
||||
/// accounts for the current page rotation.
|
||||
InkStroke _normalizeStroke(InkStroke stroke) {
|
||||
if (_canvasSize == Size.zero) return stroke;
|
||||
return InkStroke(
|
||||
id: stroke.id,
|
||||
points: stroke.points.map((pt) {
|
||||
final nx = pt.x / _canvasSize.width;
|
||||
final ny = pt.y / _canvasSize.height;
|
||||
final rotated = _forwardRotate(nx, ny, widget.rotation);
|
||||
return InkPoint(
|
||||
x: rotated.dx,
|
||||
y: rotated.dy,
|
||||
pressure: pt.pressure,
|
||||
tilt: pt.tilt,
|
||||
timestamp: pt.timestamp,
|
||||
pointerDeviceKind: pt.pointerDeviceKind,
|
||||
);
|
||||
}).toList(),
|
||||
tool: stroke.tool,
|
||||
color: stroke.color,
|
||||
strokeWidth: stroke.strokeWidth,
|
||||
createdAt: stroke.createdAt,
|
||||
filled: stroke.filled,
|
||||
textContent: stroke.textContent,
|
||||
fontSize: stroke.fontSize,
|
||||
);
|
||||
}
|
||||
|
||||
void _onStrokeComplete(InkStroke stroke) {
|
||||
widget.onStrokeComplete?.call(_normalizeStroke(stroke));
|
||||
}
|
||||
|
||||
void _onErase(String strokeId, List<InkStroke> replacements) {
|
||||
widget.onErase?.call(strokeId, replacements.map(_normalizeStroke).toList());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
_canvasSize = Size(constraints.maxWidth, constraints.maxHeight);
|
||||
return InkCanvas(
|
||||
strokes: _scaledStrokes,
|
||||
onStrokeComplete: _onStrokeComplete,
|
||||
onErase: _onErase,
|
||||
tool: widget.tool,
|
||||
color: widget.color,
|
||||
strokeWidth: widget.strokeWidth,
|
||||
filled: widget.filled,
|
||||
interactionMode: widget.interactionMode,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user