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>
170 lines
4.8 KiB
Dart
170 lines
4.8 KiB
Dart
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}',
|
|
),
|
|
),
|
|
);
|
|
}
|