feat: PDF sources: read, take notes and cards from pages, deadlines
PDFs live in a studyset's new Files tab: uploaded from Add material (a file the studyset holds already asks first), each row with its pages, cards, notes and size, how far it is read and its reading deadline; renamed, given a deadline (a page or a chapter by a day), marked as read, started afresh, deleted with a moment to undo. The reader (/pdfs/:id) opens where reading left off: on wide windows a rail of tools (search, fit, zoom, fullscreen), a page counter with the chapter, and a sidebar; on phones a top bar, a page scrubber and a sheet. The sidebar holds the PDF's flashcards, by page or chapter, made from selected text or written there, each tied to its page and tagged with its chapter; and the page's note, an ordinary note tied to the page. Screenshots of a region become images that know their page. Cards and screenshots lead back to their page; reading on past a chapter with cards suggests studying it. Home shows the deadlines running and to come, and a timeline of them. Server: pdf_source rows (content in the payload, last writer wins) and an append-only pdf_reading log, the furthest page derived from it so devices never conflict. Files up to 50 MB for PDFs (20 MB for others; a larger file that isn't a PDF is deleted once stored). A deleted PDF takes its file's asset row along unless another PDF has the same file. Card payload 4 and note payload 2 keep the page. The reader draws with pdfrx (PDFium) behind PdfEngine in Dependencies; widget tests use a fake, and integration_test/pdf_engine_test.dart checks the real engine on a device or the desktop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
ec34bb2334
commit
b96db731c1
1 changed files with 34926 additions and 58 deletions
170
halkyon_learn_flutter/test/support/fake_pdf_engine.dart
Normal file
170
halkyon_learn_flutter/test/support/fake_pdf_engine.dart
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_engine.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A [PdfEngine] without PDFium, which `flutter test` can't load. A PDF's
|
||||
/// bytes say what it holds: `pages:12` its page count (10 without), and
|
||||
/// `chapter:<page>:<title>;` each chapter. Its viewer shows "Page N of M"
|
||||
/// and does what it is asked at once; [select] plays a selection.
|
||||
class FakePdfEngine() implements PdfEngine {
|
||||
/// The viewers built, the last one showing.
|
||||
final viewers = <FakePdfViewer>[];
|
||||
|
||||
/// The regions drawn, in order.
|
||||
final rendered = <({int page, Rect region})>[];
|
||||
|
||||
/// What [PdfDocumentView.renderRegion] gives: a PNG's bytes.
|
||||
Uint8List image = Uint8List.fromList(const [137, 80, 78, 71, 1, 2, 3]);
|
||||
|
||||
/// Whether the next PDF opened can't be read.
|
||||
bool failNext = false;
|
||||
|
||||
/// Selects [text] in the viewer showing.
|
||||
void select(String text) =>
|
||||
viewers.last.controller.report(selectedText: text);
|
||||
|
||||
@override
|
||||
Future<PdfDocumentView> open(Uint8List bytes) async {
|
||||
final text = latin1.decode(bytes, allowInvalid: true);
|
||||
if (failNext || !text.startsWith('%PDF-')) {
|
||||
failNext = false;
|
||||
throw const FormatException('Not a PDF.');
|
||||
}
|
||||
final pages =
|
||||
int.tryParse(
|
||||
RegExp(r'pages:(\d+)').firstMatch(text)?.group(1) ?? '',
|
||||
) ??
|
||||
10;
|
||||
return _FakeDocument(this, pages, [
|
||||
for (final match in RegExp(r'chapter:(\d+):([^;]+);').allMatches(text))
|
||||
(title: match[2]!, page: int.parse(match[1]!)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeDocument(
|
||||
final FakePdfEngine _engine,
|
||||
final int _pages,
|
||||
final List<PdfChapter> _chapters,
|
||||
) implements PdfDocumentView {
|
||||
@override
|
||||
int get pageCount => _pages;
|
||||
|
||||
@override
|
||||
Future<List<PdfChapter>> chapters() async => _chapters;
|
||||
|
||||
@override
|
||||
Future<Uint8List?> renderRegion(int page, Rect region) async {
|
||||
_engine.rendered.add((page: page, region: region));
|
||||
return _engine.image;
|
||||
}
|
||||
|
||||
final _viewers = <PdfReaderController, FakePdfViewer>{};
|
||||
|
||||
@override
|
||||
Widget viewer(PdfReaderController controller) {
|
||||
// One viewer per controller, however often the reader is built.
|
||||
final viewer = _viewers.putIfAbsent(controller, () {
|
||||
final viewer = FakePdfViewer(controller, _pages);
|
||||
_engine.viewers.add(viewer);
|
||||
return viewer;
|
||||
});
|
||||
return _FakeViewerWidget(viewer);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
/// The fake viewer's handle: pages 1000 high, 700 wide, one below the other.
|
||||
class FakePdfViewer(final PdfReaderController controller, final int pages)
|
||||
implements PdfViewerHandle {
|
||||
/// The texts searched for, in order.
|
||||
final searched = <String>[];
|
||||
|
||||
@override
|
||||
Future<void> goToPage(int page) async =>
|
||||
controller.report(page: page.clamp(1, pages));
|
||||
|
||||
@override
|
||||
Future<void> zoomIn() async =>
|
||||
controller.report(zoom: controller.zoom * 1.25);
|
||||
|
||||
@override
|
||||
Future<void> zoomOut() async =>
|
||||
controller.report(zoom: controller.zoom / 1.25);
|
||||
|
||||
@override
|
||||
Future<void> fitWidth() async => controller.report(zoom: 1);
|
||||
|
||||
@override
|
||||
Future<void> fitHeight() async => controller.report(zoom: 0.5);
|
||||
|
||||
@override
|
||||
Future<void> setZoom(double zoom) async => controller.report(zoom: zoom);
|
||||
|
||||
@override
|
||||
void search(String text) {
|
||||
searched.add(text);
|
||||
controller.report(matchCount: 3, currentMatch: 0);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> nextMatch() async => controller.report(
|
||||
currentMatch: ((controller.currentMatch ?? -1) + 1) % 3,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> previousMatch() async => controller.report(
|
||||
currentMatch: ((controller.currentMatch ?? 1) + 2) % 3,
|
||||
);
|
||||
|
||||
@override
|
||||
void clearSearch() => controller.report(matchCount: 0, noMatch: true);
|
||||
|
||||
@override
|
||||
Rect? pageRect(int page) =>
|
||||
Rect.fromLTWH(0, (page - controller.page) * 1000.0, 700, 1000);
|
||||
}
|
||||
|
||||
class const _FakeViewerWidget(final FakePdfViewer _viewer)
|
||||
extends StatefulWidget {
|
||||
@override
|
||||
State<_FakeViewerWidget> createState() => _FakeViewerWidgetState();
|
||||
}
|
||||
|
||||
class _FakeViewerWidgetState() extends State<_FakeViewerWidget> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget._viewer.controller.addListener(_changed);
|
||||
// As pdfrx's viewer, ready once laid out.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
widget._viewer.controller.attach(widget._viewer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget._viewer.controller
|
||||
..removeListener(_changed)
|
||||
..attach(null);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _changed() => setState(() {});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ColoredBox(
|
||||
color: const Color(0xffdddddd),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Page ${widget._viewer.controller.page} of ${widget._viewer.pages}',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue