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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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