feat: PDF sources: read, take notes and cards from pages, deadlines
Some checks failed
CI / check (push) Failing after 26s
CI / deploy (push) Has been skipped

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:
Tizian 「ティツィアーン」 2026-10-03 14:40:08 +02:00
commit b96db731c1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
72 changed files with 34926 additions and 58 deletions

View file

@ -0,0 +1,135 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_engine.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdfrx_engine.dart';
import 'package:integration_test/integration_test.dart';
import 'package:material_ui/material_ui.dart';
// The app's PDF engine for real, PDFium included, which `flutter test`
// can't load: on a device or the desktop,
//
// flutter test integration_test/pdf_engine_test.dart -d linux
//
// on a PDF of three pages, "Hello page N" on each, with an outline of two
// chapters.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
setUpAll(PdfrxEngine.initialize);
testWidgets('opens a PDF, reads its chapters and draws a region', (
tester,
) async {
final document = await tester.runAsync(
() => PdfrxEngine().open(_threePages()),
);
addTearDown(() => tester.runAsync(document!.dispose));
expect(document!.pageCount, 3);
expect(await tester.runAsync(document.chapters), [
(title: 'One', page: 1),
(title: 'Two', page: 3),
]);
final png = await tester.runAsync(
() => document.renderRegion(1, const Rect.fromLTWH(0, 0, 0.5, 0.5)),
);
expect(png!.sublist(0, 4), [0x89, 0x50, 0x4e, 0x47]);
});
testWidgets('refuses a file that is no PDF', (tester) async {
// runAsync reports what is thrown inside instead of passing it on.
final refused = await tester.runAsync(() async {
try {
await PdfrxEngine().open(Uint8List.fromList(utf8.encode('text')));
return false;
} on FormatException {
return true;
}
});
expect(refused, isTrue);
});
testWidgets('its viewer turns pages and finds text', (tester) async {
final document = await tester.runAsync(
() => PdfrxEngine().open(_threePages()),
);
final controller = PdfReaderController();
addTearDown(controller.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 600,
height: 800,
child: document!.viewer(controller),
),
),
),
);
Future<void> until(bool Function() test) async {
final waited = Stopwatch()..start();
while (!test() && waited.elapsed < const Duration(seconds: 20)) {
await tester.pump(const Duration(milliseconds: 100));
}
}
await until(() => controller.isReady);
expect(controller.isReady, isTrue);
// The page turns over an animation, whose frames the test drives: it
// is started, then pumped until it arrives.
unawaited(controller.goToPage(3));
await until(() => controller.page == 3);
expect(controller.page, 3);
controller.search('hello');
await until(() => controller.matchCount == 3);
expect(controller.matchCount, 3);
expect(controller.pageRect(3), isNotNull);
});
}
/// A PDF of three pages, each saying "Hello page N", with an outline of
/// chapter "One" at page 1 and "Two" at page 3.
Uint8List _threePages() {
String text(int page) {
final stream = 'BT /F1 24 Tf 40 700 Td (Hello page $page) Tj ET';
return '<< /Length ${stream.length} >>\nstream\n$stream\nendstream';
}
String pageOf(int page) =>
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] '
'/Contents ${5 + page} 0 R /Resources << /Font << /F1 9 0 R >> >> >>';
final objects = [
'<< /Type /Catalog /Pages 2 0 R /Outlines 10 0 R >>',
'<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 >>',
for (final page in [1, 2, 3]) pageOf(page),
for (final page in [1, 2, 3]) text(page),
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
'<< /Type /Outlines /First 11 0 R /Last 12 0 R /Count 2 >>',
'<< /Title (One) /Parent 10 0 R /Next 12 0 R /Dest [3 0 R /Fit] >>',
'<< /Title (Two) /Parent 10 0 R /Prev 11 0 R /Dest [5 0 R /Fit] >>',
];
final out = StringBuffer('%PDF-1.7\n');
final offsets = <int>[];
for (final (index, object) in objects.indexed) {
offsets.add(out.length);
out.write('${index + 1} 0 obj\n$object\nendobj\n');
}
final xref = out.length;
out
..write('xref\n0 ${objects.length + 1}\n0000000000 65535 f \n')
..writeAll([
for (final offset in offsets)
'${offset.toString().padLeft(10, '0')} 00000 n \n',
])
..write(
'trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n'
'startxref\n$xref\n%%EOF\n',
);
return Uint8List.fromList(latin1.encode(out.toString()));
}

View file

@ -655,6 +655,185 @@
"studyButtonRemove": "Remove study button",
"studyButtonBroken": "This study button doesn't say what to study.",
"studyButtonGone": "That studyset no longer exists.",
"pdfPageField": "PDF page",
"studysetTabFiles": "Files",
"pdfUploadMenu": "Upload a PDF",
"pdfFileType": "PDFs",
"pdfAdded": "Added \"{title}\".",
"@pdfAdded": {"placeholders": {"title": {"type": "String"}}},
"pdfNotPdf": "This file isn't a PDF.",
"pdfTooLarge": "This PDF is larger than {megabytes} MB.",
"@pdfTooLarge": {"placeholders": {"megabytes": {"type": "int"}}},
"pdfDuplicateTitle": "PDF already here",
"pdfDuplicateMessage": "This file is \"{title}\", which this studyset holds already. Add it as a copy, or keep the one there?",
"@pdfDuplicateMessage": {"placeholders": {"title": {"type": "String"}}},
"pdfDuplicateKeep": "Keep the one there",
"pdfDuplicateAdd": "Add a copy",
"filesEmpty": "No files yet",
"filesEmptyHint": "Use \"Add material → Upload a PDF\" to add one.",
"filesSort": "Sort",
"filesSortName": "Name (A–Z)",
"filesSortNewest": "Newest first",
"filesSortOldest": "Oldest first",
"filesSortLargest": "Largest first",
"pdfPages": "{count, plural, =1{1 page} other{{count} pages}}",
"@pdfPages": {"placeholders": {"count": {"type": "int"}}},
"pdfPercentRead": "{percent}% read",
"@pdfPercentRead": {"placeholders": {"percent": {"type": "int"}}},
"pdfDeadlineMet": "Reading deadline met",
"pdfDeadlineUpcoming": "Starts in {days, plural, =1{1 day} other{{days} days}} — {pages}/day planned",
"@pdfDeadlineUpcoming": {"placeholders": {"days": {"type": "int"}, "pages": {"type": "int"}}},
"pdfDeadlinePassed": "Deadline passed — {pages, plural, =1{1 page} other{{pages} pages}} left",
"@pdfDeadlinePassed": {"placeholders": {"pages": {"type": "int"}}},
"pdfDeadlineToday": "Due today — {pages, plural, =1{1 page} other{{pages} pages}} left",
"@pdfDeadlineToday": {"placeholders": {"pages": {"type": "int"}}},
"pdfDeadlinePace": "{pages}/day to reach page {target} in {days, plural, =1{1 day} other{{days} days}}",
"@pdfDeadlinePace": {"placeholders": {"pages": {"type": "int"}, "target": {"type": "int"}, "days": {"type": "int"}}},
"pdfOptions": "Actions",
"pdfRename": "Rename",
"pdfRenameTitle": "Rename PDF",
"pdfRenameLabel": "Title",
"pdfSetDeadline": "Set reading deadline",
"pdfEditDeadline": "Edit reading deadline",
"pdfMarkFinished": "Mark as finished",
"pdfResetReading": "Reset reading progress",
"pdfResetTitle": "Reset reading progress?",
"pdfResetMessage": "How far you've read \"{title}\" is forgotten. The PDF, its flashcards and notes stay.",
"@pdfResetMessage": {"placeholders": {"title": {"type": "String"}}},
"pdfResetConfirm": "Reset",
"pdfMoreInfo": "More info",
"pdfInfoFileName": "File name",
"pdfInfoSize": "Size",
"pdfInfoPages": "Pages",
"pdfInfoUnknown": "Known once it is opened",
"pdfDelete": "Delete",
"pdfDeleteTitle": "Delete PDF?",
"pdfDeleteMessage": "\"{title}\" is removed from this studyset, with how far it was read. Flashcards and notes made from it stay.",
"@pdfDeleteMessage": {"placeholders": {"title": {"type": "String"}}},
"pdfDeleteConfirm": "Delete",
"pdfDeleted": "PDF deleted",
"deadlineSheetSet": "Set a reading deadline",
"deadlineSheetEdit": "Edit reading deadline",
"deadlineSheetHelp": "Reach a page by a day. Progress follows the furthest page you reach as you read.",
"deadlineReachPageOf": "Reach page (of {count})",
"@deadlineReachPageOf": {"placeholders": {"count": {"type": "int"}}},
"deadlineReachPage": "Reach page",
"deadlineDecrease": "Decrease",
"deadlineIncrease": "Increase",
"deadlineChapter": "Or finish a chapter",
"deadlineChapterEntry": "{title} · to p.{page}",
"@deadlineChapterEntry": {"placeholders": {"title": {"type": "String"}, "page": {"type": "int"}}},
"deadlineBy": "By",
"deadlineStart": "Start on",
"deadlineStartToday": "Today",
"deadlineStartReset": "Start today",
"deadlineStartHelp": "The daily pace counts from this day on. Until then the PDF waits under \"Upcoming\".",
"deadlineSave": "Save deadline",
"deadlineRemove": "Remove deadline",
"readerMissing": "This PDF isn't on this device yet. It opens once the device that added it has uploaded it and you're online.",
"readerUnreadable": "This file can't be read as a PDF.",
"readerBack": "Back to studyset",
"readerSearch": "Search PDF",
"readerFitWidth": "Fit width",
"readerFitHeight": "Fit height",
"readerZoomIn": "Zoom in",
"readerZoomOut": "Zoom out",
"readerSetZoom": "Set zoom",
"readerZoomTitle": "Zoom",
"readerZoomApply": "Apply",
"readerFullscreen": "Fullscreen",
"readerExitFullscreen": "Exit fullscreen",
"readerPreviousPage": "Previous page",
"readerNextPage": "Next page",
"readerPageOf": "/ {count}",
"@readerPageOf": {"placeholders": {"count": {"type": "int"}}},
"readerFindHint": "Find in PDF",
"readerMatchOf": "{current} of {total}",
"@readerMatchOf": {"placeholders": {"current": {"type": "int"}, "total": {"type": "int"}}},
"readerPreviousMatch": "Previous match",
"readerNextMatch": "Next match",
"readerCloseSearch": "Close search",
"readerExpand": "Expand",
"readerCollapse": "Collapse",
"readerTabFlashcards": "Flashcards",
"readerTabNotes": "Notes",
"readerCardCreated": "Flashcard created",
"readerCardDeleteTitle": "Delete flashcard?",
"readerCardDeleteMessage": "The flashcard is deleted, with how it was studied.",
"readerCardDeleteConfirm": "Delete",
"readerFrontMatter": "Front matter",
"readerPage": "Page {page}",
"@readerPage": {"placeholders": {"page": {"type": "int"}}},
"readerByPage": "Page",
"readerByChapter": "Chapter",
"readerCardsEmpty": "Select text to make flashcards in no time",
"readerCardsEmptyHint": "Select the lines that matter and send them to a question or an answer, or press \"Create flashcard\".",
"readerCreateCard": "Create flashcard",
"readerEditCard": "Edit flashcard",
"readerBackToCards": "Back to flashcards",
"readerReferencedPage": "Referenced page: {page}",
"@readerReferencedPage": {"placeholders": {"page": {"type": "int"}}},
"readerUseCurrentPage": "Use current ({page})",
"@readerUseCurrentPage": {"placeholders": {"page": {"type": "int"}}},
"readerQuestion": "Question",
"readerAnswer": "Answer",
"readerKeepOpen": "Keep creation mode open",
"readerClear": "Clear",
"readerCreate": "Create",
"readerUntitledCard": "Untitled flashcard",
"readerCardOptions": "Card options",
"readerNoteSaved": "Saved",
"readerNoteEditing": "Editing…",
"readerNoteSaving": "Saving…",
"readerNoteFailed": "Save failed",
"readerToQuestion": "Question",
"readerToAnswer": "Answer",
"readerToNote": "Notes",
"readerLineBreaksJoined": "Line breaks joined (tap to keep)",
"readerLineBreaksKept": "Line breaks kept (tap to join)",
"blockScreenshot": "Screenshot of the page",
"readerCaptureCancel": "Cancel screenshot",
"readerCaptureHint": "Drag over the part of the page to keep",
"readerCaptureFailed": "The page couldn't be drawn. Try again once it has loaded.",
"readerScreenshotSaved": "Screenshot saved",
"imageOpenPdfPage": "Open the PDF at this page",
"homeWidgetDeadlines": "Reading deadlines",
"deadlinesTitle": "Reading deadlines",
"deadlinesCurrent": "Current",
"deadlinesUpcoming": "Upcoming",
"deadlinesProgress": "p.{furthest}/{target} · {when}",
"@deadlinesProgress": {"placeholders": {"furthest": {"type": "int"}, "target": {"type": "int"}, "when": {"type": "String"}}},
"deadlinePassedShort": "deadline passed",
"deadlineDueTodayShort": "due today",
"deadlineDaysLeft": "{days, plural, =1{1 day left} other{{days} days left}}",
"@deadlineDaysLeft": {"placeholders": {"days": {"type": "int"}}},
"deadlineToPageToday": "to p.{page} today",
"@deadlineToPageToday": {"placeholders": {"page": {"type": "int"}}},
"deadlineStartsIn": "Starts in {days, plural, =1{1 day} other{{days} days}}",
"@deadlineStartsIn": {"placeholders": {"days": {"type": "int"}}},
"deadlinesPlaceholder": "Appears when you have reading deadlines",
"deadlinesOpenTimeline": "Open the timeline",
"timelineTitle": "Reading timeline",
"timelineEmpty": "No reading deadlines yet",
"timelineEmptyHint": "Set a reading deadline on a PDF to see it here.",
"timelineToday": "Today",
"timelinePages": "p.{furthest}/{target}",
"@timelinePages": {"placeholders": {"furthest": {"type": "int"}, "target": {"type": "int"}}},
"chapterFinished": "Finished “{chapter}” — study the {count, plural, =1{card} other{{count} cards}} you made?",
"@chapterFinished": {"placeholders": {"chapter": {"type": "String"}, "count": {"type": "int"}}},
"chapterStudyNow": "Study now",
"chapterDismiss": "Dismiss",
"deadlineChapterLoading": "Loading chapters…",
"fileSizeKb": "{size} KB",
"@fileSizeKb": {"placeholders": {"size": {"type": "int"}}},
"fileSizeMb": "{size} MB",
"@fileSizeMb": {"placeholders": {"size": {"type": "String"}}},
"pdfPageShort": "p. {page}",
"@pdfPageShort": {
"placeholders": {
"page": {"type": "int"}
}
},
"ankiImportMenu": "Import from Anki",
"ankiImportFileType": "Anki decks",
"ankiImportTitle": "Import Anki deck",

View file

@ -3448,6 +3448,894 @@ abstract class AppLocalizations {
/// **'That studyset no longer exists.'**
String get studyButtonGone;
/// No description provided for @pdfPageField.
///
/// In en, this message translates to:
/// **'PDF page'**
String get pdfPageField;
/// No description provided for @studysetTabFiles.
///
/// In en, this message translates to:
/// **'Files'**
String get studysetTabFiles;
/// No description provided for @pdfUploadMenu.
///
/// In en, this message translates to:
/// **'Upload a PDF'**
String get pdfUploadMenu;
/// No description provided for @pdfFileType.
///
/// In en, this message translates to:
/// **'PDFs'**
String get pdfFileType;
/// No description provided for @pdfAdded.
///
/// In en, this message translates to:
/// **'Added \"{title}\".'**
String pdfAdded(String title);
/// No description provided for @pdfNotPdf.
///
/// In en, this message translates to:
/// **'This file isn\'t a PDF.'**
String get pdfNotPdf;
/// No description provided for @pdfTooLarge.
///
/// In en, this message translates to:
/// **'This PDF is larger than {megabytes} MB.'**
String pdfTooLarge(int megabytes);
/// No description provided for @pdfDuplicateTitle.
///
/// In en, this message translates to:
/// **'PDF already here'**
String get pdfDuplicateTitle;
/// No description provided for @pdfDuplicateMessage.
///
/// In en, this message translates to:
/// **'This file is \"{title}\", which this studyset holds already. Add it as a copy, or keep the one there?'**
String pdfDuplicateMessage(String title);
/// No description provided for @pdfDuplicateKeep.
///
/// In en, this message translates to:
/// **'Keep the one there'**
String get pdfDuplicateKeep;
/// No description provided for @pdfDuplicateAdd.
///
/// In en, this message translates to:
/// **'Add a copy'**
String get pdfDuplicateAdd;
/// No description provided for @filesEmpty.
///
/// In en, this message translates to:
/// **'No files yet'**
String get filesEmpty;
/// No description provided for @filesEmptyHint.
///
/// In en, this message translates to:
/// **'Use \"Add material → Upload a PDF\" to add one.'**
String get filesEmptyHint;
/// No description provided for @filesSort.
///
/// In en, this message translates to:
/// **'Sort'**
String get filesSort;
/// No description provided for @filesSortName.
///
/// In en, this message translates to:
/// **'Name (A–Z)'**
String get filesSortName;
/// No description provided for @filesSortNewest.
///
/// In en, this message translates to:
/// **'Newest first'**
String get filesSortNewest;
/// No description provided for @filesSortOldest.
///
/// In en, this message translates to:
/// **'Oldest first'**
String get filesSortOldest;
/// No description provided for @filesSortLargest.
///
/// In en, this message translates to:
/// **'Largest first'**
String get filesSortLargest;
/// No description provided for @pdfPages.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 page} other{{count} pages}}'**
String pdfPages(int count);
/// No description provided for @pdfPercentRead.
///
/// In en, this message translates to:
/// **'{percent}% read'**
String pdfPercentRead(int percent);
/// No description provided for @pdfDeadlineMet.
///
/// In en, this message translates to:
/// **'Reading deadline met'**
String get pdfDeadlineMet;
/// No description provided for @pdfDeadlineUpcoming.
///
/// In en, this message translates to:
/// **'Starts in {days, plural, =1{1 day} other{{days} days}} — {pages}/day planned'**
String pdfDeadlineUpcoming(int days, int pages);
/// No description provided for @pdfDeadlinePassed.
///
/// In en, this message translates to:
/// **'Deadline passed — {pages, plural, =1{1 page} other{{pages} pages}} left'**
String pdfDeadlinePassed(int pages);
/// No description provided for @pdfDeadlineToday.
///
/// In en, this message translates to:
/// **'Due today — {pages, plural, =1{1 page} other{{pages} pages}} left'**
String pdfDeadlineToday(int pages);
/// No description provided for @pdfDeadlinePace.
///
/// In en, this message translates to:
/// **'{pages}/day to reach page {target} in {days, plural, =1{1 day} other{{days} days}}'**
String pdfDeadlinePace(int pages, int target, int days);
/// No description provided for @pdfOptions.
///
/// In en, this message translates to:
/// **'Actions'**
String get pdfOptions;
/// No description provided for @pdfRename.
///
/// In en, this message translates to:
/// **'Rename'**
String get pdfRename;
/// No description provided for @pdfRenameTitle.
///
/// In en, this message translates to:
/// **'Rename PDF'**
String get pdfRenameTitle;
/// No description provided for @pdfRenameLabel.
///
/// In en, this message translates to:
/// **'Title'**
String get pdfRenameLabel;
/// No description provided for @pdfSetDeadline.
///
/// In en, this message translates to:
/// **'Set reading deadline'**
String get pdfSetDeadline;
/// No description provided for @pdfEditDeadline.
///
/// In en, this message translates to:
/// **'Edit reading deadline'**
String get pdfEditDeadline;
/// No description provided for @pdfMarkFinished.
///
/// In en, this message translates to:
/// **'Mark as finished'**
String get pdfMarkFinished;
/// No description provided for @pdfResetReading.
///
/// In en, this message translates to:
/// **'Reset reading progress'**
String get pdfResetReading;
/// No description provided for @pdfResetTitle.
///
/// In en, this message translates to:
/// **'Reset reading progress?'**
String get pdfResetTitle;
/// No description provided for @pdfResetMessage.
///
/// In en, this message translates to:
/// **'How far you\'ve read \"{title}\" is forgotten. The PDF, its flashcards and notes stay.'**
String pdfResetMessage(String title);
/// No description provided for @pdfResetConfirm.
///
/// In en, this message translates to:
/// **'Reset'**
String get pdfResetConfirm;
/// No description provided for @pdfMoreInfo.
///
/// In en, this message translates to:
/// **'More info'**
String get pdfMoreInfo;
/// No description provided for @pdfInfoFileName.
///
/// In en, this message translates to:
/// **'File name'**
String get pdfInfoFileName;
/// No description provided for @pdfInfoSize.
///
/// In en, this message translates to:
/// **'Size'**
String get pdfInfoSize;
/// No description provided for @pdfInfoPages.
///
/// In en, this message translates to:
/// **'Pages'**
String get pdfInfoPages;
/// No description provided for @pdfInfoUnknown.
///
/// In en, this message translates to:
/// **'Known once it is opened'**
String get pdfInfoUnknown;
/// No description provided for @pdfDelete.
///
/// In en, this message translates to:
/// **'Delete'**
String get pdfDelete;
/// No description provided for @pdfDeleteTitle.
///
/// In en, this message translates to:
/// **'Delete PDF?'**
String get pdfDeleteTitle;
/// No description provided for @pdfDeleteMessage.
///
/// In en, this message translates to:
/// **'\"{title}\" is removed from this studyset, with how far it was read. Flashcards and notes made from it stay.'**
String pdfDeleteMessage(String title);
/// No description provided for @pdfDeleteConfirm.
///
/// In en, this message translates to:
/// **'Delete'**
String get pdfDeleteConfirm;
/// No description provided for @pdfDeleted.
///
/// In en, this message translates to:
/// **'PDF deleted'**
String get pdfDeleted;
/// No description provided for @deadlineSheetSet.
///
/// In en, this message translates to:
/// **'Set a reading deadline'**
String get deadlineSheetSet;
/// No description provided for @deadlineSheetEdit.
///
/// In en, this message translates to:
/// **'Edit reading deadline'**
String get deadlineSheetEdit;
/// No description provided for @deadlineSheetHelp.
///
/// In en, this message translates to:
/// **'Reach a page by a day. Progress follows the furthest page you reach as you read.'**
String get deadlineSheetHelp;
/// No description provided for @deadlineReachPageOf.
///
/// In en, this message translates to:
/// **'Reach page (of {count})'**
String deadlineReachPageOf(int count);
/// No description provided for @deadlineReachPage.
///
/// In en, this message translates to:
/// **'Reach page'**
String get deadlineReachPage;
/// No description provided for @deadlineDecrease.
///
/// In en, this message translates to:
/// **'Decrease'**
String get deadlineDecrease;
/// No description provided for @deadlineIncrease.
///
/// In en, this message translates to:
/// **'Increase'**
String get deadlineIncrease;
/// No description provided for @deadlineChapter.
///
/// In en, this message translates to:
/// **'Or finish a chapter'**
String get deadlineChapter;
/// No description provided for @deadlineChapterEntry.
///
/// In en, this message translates to:
/// **'{title} · to p.{page}'**
String deadlineChapterEntry(String title, int page);
/// No description provided for @deadlineBy.
///
/// In en, this message translates to:
/// **'By'**
String get deadlineBy;
/// No description provided for @deadlineStart.
///
/// In en, this message translates to:
/// **'Start on'**
String get deadlineStart;
/// No description provided for @deadlineStartToday.
///
/// In en, this message translates to:
/// **'Today'**
String get deadlineStartToday;
/// No description provided for @deadlineStartReset.
///
/// In en, this message translates to:
/// **'Start today'**
String get deadlineStartReset;
/// No description provided for @deadlineStartHelp.
///
/// In en, this message translates to:
/// **'The daily pace counts from this day on. Until then the PDF waits under \"Upcoming\".'**
String get deadlineStartHelp;
/// No description provided for @deadlineSave.
///
/// In en, this message translates to:
/// **'Save deadline'**
String get deadlineSave;
/// No description provided for @deadlineRemove.
///
/// In en, this message translates to:
/// **'Remove deadline'**
String get deadlineRemove;
/// No description provided for @readerMissing.
///
/// In en, this message translates to:
/// **'This PDF isn\'t on this device yet. It opens once the device that added it has uploaded it and you\'re online.'**
String get readerMissing;
/// No description provided for @readerUnreadable.
///
/// In en, this message translates to:
/// **'This file can\'t be read as a PDF.'**
String get readerUnreadable;
/// No description provided for @readerBack.
///
/// In en, this message translates to:
/// **'Back to studyset'**
String get readerBack;
/// No description provided for @readerSearch.
///
/// In en, this message translates to:
/// **'Search PDF'**
String get readerSearch;
/// No description provided for @readerFitWidth.
///
/// In en, this message translates to:
/// **'Fit width'**
String get readerFitWidth;
/// No description provided for @readerFitHeight.
///
/// In en, this message translates to:
/// **'Fit height'**
String get readerFitHeight;
/// No description provided for @readerZoomIn.
///
/// In en, this message translates to:
/// **'Zoom in'**
String get readerZoomIn;
/// No description provided for @readerZoomOut.
///
/// In en, this message translates to:
/// **'Zoom out'**
String get readerZoomOut;
/// No description provided for @readerSetZoom.
///
/// In en, this message translates to:
/// **'Set zoom'**
String get readerSetZoom;
/// No description provided for @readerZoomTitle.
///
/// In en, this message translates to:
/// **'Zoom'**
String get readerZoomTitle;
/// No description provided for @readerZoomApply.
///
/// In en, this message translates to:
/// **'Apply'**
String get readerZoomApply;
/// No description provided for @readerFullscreen.
///
/// In en, this message translates to:
/// **'Fullscreen'**
String get readerFullscreen;
/// No description provided for @readerExitFullscreen.
///
/// In en, this message translates to:
/// **'Exit fullscreen'**
String get readerExitFullscreen;
/// No description provided for @readerPreviousPage.
///
/// In en, this message translates to:
/// **'Previous page'**
String get readerPreviousPage;
/// No description provided for @readerNextPage.
///
/// In en, this message translates to:
/// **'Next page'**
String get readerNextPage;
/// No description provided for @readerPageOf.
///
/// In en, this message translates to:
/// **'/ {count}'**
String readerPageOf(int count);
/// No description provided for @readerFindHint.
///
/// In en, this message translates to:
/// **'Find in PDF'**
String get readerFindHint;
/// No description provided for @readerMatchOf.
///
/// In en, this message translates to:
/// **'{current} of {total}'**
String readerMatchOf(int current, int total);
/// No description provided for @readerPreviousMatch.
///
/// In en, this message translates to:
/// **'Previous match'**
String get readerPreviousMatch;
/// No description provided for @readerNextMatch.
///
/// In en, this message translates to:
/// **'Next match'**
String get readerNextMatch;
/// No description provided for @readerCloseSearch.
///
/// In en, this message translates to:
/// **'Close search'**
String get readerCloseSearch;
/// No description provided for @readerExpand.
///
/// In en, this message translates to:
/// **'Expand'**
String get readerExpand;
/// No description provided for @readerCollapse.
///
/// In en, this message translates to:
/// **'Collapse'**
String get readerCollapse;
/// No description provided for @readerTabFlashcards.
///
/// In en, this message translates to:
/// **'Flashcards'**
String get readerTabFlashcards;
/// No description provided for @readerTabNotes.
///
/// In en, this message translates to:
/// **'Notes'**
String get readerTabNotes;
/// No description provided for @readerCardCreated.
///
/// In en, this message translates to:
/// **'Flashcard created'**
String get readerCardCreated;
/// No description provided for @readerCardDeleteTitle.
///
/// In en, this message translates to:
/// **'Delete flashcard?'**
String get readerCardDeleteTitle;
/// No description provided for @readerCardDeleteMessage.
///
/// In en, this message translates to:
/// **'The flashcard is deleted, with how it was studied.'**
String get readerCardDeleteMessage;
/// No description provided for @readerCardDeleteConfirm.
///
/// In en, this message translates to:
/// **'Delete'**
String get readerCardDeleteConfirm;
/// No description provided for @readerFrontMatter.
///
/// In en, this message translates to:
/// **'Front matter'**
String get readerFrontMatter;
/// No description provided for @readerPage.
///
/// In en, this message translates to:
/// **'Page {page}'**
String readerPage(int page);
/// No description provided for @readerByPage.
///
/// In en, this message translates to:
/// **'Page'**
String get readerByPage;
/// No description provided for @readerByChapter.
///
/// In en, this message translates to:
/// **'Chapter'**
String get readerByChapter;
/// No description provided for @readerCardsEmpty.
///
/// In en, this message translates to:
/// **'Select text to make flashcards in no time'**
String get readerCardsEmpty;
/// No description provided for @readerCardsEmptyHint.
///
/// In en, this message translates to:
/// **'Select the lines that matter and send them to a question or an answer, or press \"Create flashcard\".'**
String get readerCardsEmptyHint;
/// No description provided for @readerCreateCard.
///
/// In en, this message translates to:
/// **'Create flashcard'**
String get readerCreateCard;
/// No description provided for @readerEditCard.
///
/// In en, this message translates to:
/// **'Edit flashcard'**
String get readerEditCard;
/// No description provided for @readerBackToCards.
///
/// In en, this message translates to:
/// **'Back to flashcards'**
String get readerBackToCards;
/// No description provided for @readerReferencedPage.
///
/// In en, this message translates to:
/// **'Referenced page: {page}'**
String readerReferencedPage(int page);
/// No description provided for @readerUseCurrentPage.
///
/// In en, this message translates to:
/// **'Use current ({page})'**
String readerUseCurrentPage(int page);
/// No description provided for @readerQuestion.
///
/// In en, this message translates to:
/// **'Question'**
String get readerQuestion;
/// No description provided for @readerAnswer.
///
/// In en, this message translates to:
/// **'Answer'**
String get readerAnswer;
/// No description provided for @readerKeepOpen.
///
/// In en, this message translates to:
/// **'Keep creation mode open'**
String get readerKeepOpen;
/// No description provided for @readerClear.
///
/// In en, this message translates to:
/// **'Clear'**
String get readerClear;
/// No description provided for @readerCreate.
///
/// In en, this message translates to:
/// **'Create'**
String get readerCreate;
/// No description provided for @readerUntitledCard.
///
/// In en, this message translates to:
/// **'Untitled flashcard'**
String get readerUntitledCard;
/// No description provided for @readerCardOptions.
///
/// In en, this message translates to:
/// **'Card options'**
String get readerCardOptions;
/// No description provided for @readerNoteSaved.
///
/// In en, this message translates to:
/// **'Saved'**
String get readerNoteSaved;
/// No description provided for @readerNoteEditing.
///
/// In en, this message translates to:
/// **'Editing…'**
String get readerNoteEditing;
/// No description provided for @readerNoteSaving.
///
/// In en, this message translates to:
/// **'Saving…'**
String get readerNoteSaving;
/// No description provided for @readerNoteFailed.
///
/// In en, this message translates to:
/// **'Save failed'**
String get readerNoteFailed;
/// No description provided for @readerToQuestion.
///
/// In en, this message translates to:
/// **'Question'**
String get readerToQuestion;
/// No description provided for @readerToAnswer.
///
/// In en, this message translates to:
/// **'Answer'**
String get readerToAnswer;
/// No description provided for @readerToNote.
///
/// In en, this message translates to:
/// **'Notes'**
String get readerToNote;
/// No description provided for @readerLineBreaksJoined.
///
/// In en, this message translates to:
/// **'Line breaks joined (tap to keep)'**
String get readerLineBreaksJoined;
/// No description provided for @readerLineBreaksKept.
///
/// In en, this message translates to:
/// **'Line breaks kept (tap to join)'**
String get readerLineBreaksKept;
/// No description provided for @blockScreenshot.
///
/// In en, this message translates to:
/// **'Screenshot of the page'**
String get blockScreenshot;
/// No description provided for @readerCaptureCancel.
///
/// In en, this message translates to:
/// **'Cancel screenshot'**
String get readerCaptureCancel;
/// No description provided for @readerCaptureHint.
///
/// In en, this message translates to:
/// **'Drag over the part of the page to keep'**
String get readerCaptureHint;
/// No description provided for @readerCaptureFailed.
///
/// In en, this message translates to:
/// **'The page couldn\'t be drawn. Try again once it has loaded.'**
String get readerCaptureFailed;
/// No description provided for @readerScreenshotSaved.
///
/// In en, this message translates to:
/// **'Screenshot saved'**
String get readerScreenshotSaved;
/// No description provided for @imageOpenPdfPage.
///
/// In en, this message translates to:
/// **'Open the PDF at this page'**
String get imageOpenPdfPage;
/// No description provided for @homeWidgetDeadlines.
///
/// In en, this message translates to:
/// **'Reading deadlines'**
String get homeWidgetDeadlines;
/// No description provided for @deadlinesTitle.
///
/// In en, this message translates to:
/// **'Reading deadlines'**
String get deadlinesTitle;
/// No description provided for @deadlinesCurrent.
///
/// In en, this message translates to:
/// **'Current'**
String get deadlinesCurrent;
/// No description provided for @deadlinesUpcoming.
///
/// In en, this message translates to:
/// **'Upcoming'**
String get deadlinesUpcoming;
/// No description provided for @deadlinesProgress.
///
/// In en, this message translates to:
/// **'p.{furthest}/{target} · {when}'**
String deadlinesProgress(int furthest, int target, String when);
/// No description provided for @deadlinePassedShort.
///
/// In en, this message translates to:
/// **'deadline passed'**
String get deadlinePassedShort;
/// No description provided for @deadlineDueTodayShort.
///
/// In en, this message translates to:
/// **'due today'**
String get deadlineDueTodayShort;
/// No description provided for @deadlineDaysLeft.
///
/// In en, this message translates to:
/// **'{days, plural, =1{1 day left} other{{days} days left}}'**
String deadlineDaysLeft(int days);
/// No description provided for @deadlineToPageToday.
///
/// In en, this message translates to:
/// **'to p.{page} today'**
String deadlineToPageToday(int page);
/// No description provided for @deadlineStartsIn.
///
/// In en, this message translates to:
/// **'Starts in {days, plural, =1{1 day} other{{days} days}}'**
String deadlineStartsIn(int days);
/// No description provided for @deadlinesPlaceholder.
///
/// In en, this message translates to:
/// **'Appears when you have reading deadlines'**
String get deadlinesPlaceholder;
/// No description provided for @deadlinesOpenTimeline.
///
/// In en, this message translates to:
/// **'Open the timeline'**
String get deadlinesOpenTimeline;
/// No description provided for @timelineTitle.
///
/// In en, this message translates to:
/// **'Reading timeline'**
String get timelineTitle;
/// No description provided for @timelineEmpty.
///
/// In en, this message translates to:
/// **'No reading deadlines yet'**
String get timelineEmpty;
/// No description provided for @timelineEmptyHint.
///
/// In en, this message translates to:
/// **'Set a reading deadline on a PDF to see it here.'**
String get timelineEmptyHint;
/// No description provided for @timelineToday.
///
/// In en, this message translates to:
/// **'Today'**
String get timelineToday;
/// No description provided for @timelinePages.
///
/// In en, this message translates to:
/// **'p.{furthest}/{target}'**
String timelinePages(int furthest, int target);
/// No description provided for @chapterFinished.
///
/// In en, this message translates to:
/// **'Finished “{chapter}” — study the {count, plural, =1{card} other{{count} cards}} you made?'**
String chapterFinished(String chapter, int count);
/// No description provided for @chapterStudyNow.
///
/// In en, this message translates to:
/// **'Study now'**
String get chapterStudyNow;
/// No description provided for @chapterDismiss.
///
/// In en, this message translates to:
/// **'Dismiss'**
String get chapterDismiss;
/// No description provided for @deadlineChapterLoading.
///
/// In en, this message translates to:
/// **'Loading chapters…'**
String get deadlineChapterLoading;
/// No description provided for @fileSizeKb.
///
/// In en, this message translates to:
/// **'{size} KB'**
String fileSizeKb(int size);
/// No description provided for @fileSizeMb.
///
/// In en, this message translates to:
/// **'{size} MB'**
String fileSizeMb(String size);
/// No description provided for @pdfPageShort.
///
/// In en, this message translates to:
/// **'p. {page}'**
String pdfPageShort(int page);
/// No description provided for @ankiImportMenu.
///
/// In en, this message translates to:

View file

@ -2005,6 +2005,560 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studyButtonGone => 'That studyset no longer exists.';
@override
String get pdfPageField => 'PDF page';
@override
String get studysetTabFiles => 'Files';
@override
String get pdfUploadMenu => 'Upload a PDF';
@override
String get pdfFileType => 'PDFs';
@override
String pdfAdded(String title) {
return 'Added \"$title\".';
}
@override
String get pdfNotPdf => 'This file isn\'t a PDF.';
@override
String pdfTooLarge(int megabytes) {
return 'This PDF is larger than $megabytes MB.';
}
@override
String get pdfDuplicateTitle => 'PDF already here';
@override
String pdfDuplicateMessage(String title) {
return 'This file is \"$title\", which this studyset holds already. Add it as a copy, or keep the one there?';
}
@override
String get pdfDuplicateKeep => 'Keep the one there';
@override
String get pdfDuplicateAdd => 'Add a copy';
@override
String get filesEmpty => 'No files yet';
@override
String get filesEmptyHint =>
'Use \"Add material → Upload a PDF\" to add one.';
@override
String get filesSort => 'Sort';
@override
String get filesSortName => 'Name (A–Z)';
@override
String get filesSortNewest => 'Newest first';
@override
String get filesSortOldest => 'Oldest first';
@override
String get filesSortLargest => 'Largest first';
@override
String pdfPages(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count pages',
one: '1 page',
);
return '$_temp0';
}
@override
String pdfPercentRead(int percent) {
return '$percent% read';
}
@override
String get pdfDeadlineMet => 'Reading deadline met';
@override
String pdfDeadlineUpcoming(int days, int pages) {
String _temp0 = intl.Intl.pluralLogic(
days,
locale: localeName,
other: '$days days',
one: '1 day',
);
return 'Starts in $_temp0 — $pages/day planned';
}
@override
String pdfDeadlinePassed(int pages) {
String _temp0 = intl.Intl.pluralLogic(
pages,
locale: localeName,
other: '$pages pages',
one: '1 page',
);
return 'Deadline passed — $_temp0 left';
}
@override
String pdfDeadlineToday(int pages) {
String _temp0 = intl.Intl.pluralLogic(
pages,
locale: localeName,
other: '$pages pages',
one: '1 page',
);
return 'Due today — $_temp0 left';
}
@override
String pdfDeadlinePace(int pages, int target, int days) {
String _temp0 = intl.Intl.pluralLogic(
days,
locale: localeName,
other: '$days days',
one: '1 day',
);
return '$pages/day to reach page $target in $_temp0';
}
@override
String get pdfOptions => 'Actions';
@override
String get pdfRename => 'Rename';
@override
String get pdfRenameTitle => 'Rename PDF';
@override
String get pdfRenameLabel => 'Title';
@override
String get pdfSetDeadline => 'Set reading deadline';
@override
String get pdfEditDeadline => 'Edit reading deadline';
@override
String get pdfMarkFinished => 'Mark as finished';
@override
String get pdfResetReading => 'Reset reading progress';
@override
String get pdfResetTitle => 'Reset reading progress?';
@override
String pdfResetMessage(String title) {
return 'How far you\'ve read \"$title\" is forgotten. The PDF, its flashcards and notes stay.';
}
@override
String get pdfResetConfirm => 'Reset';
@override
String get pdfMoreInfo => 'More info';
@override
String get pdfInfoFileName => 'File name';
@override
String get pdfInfoSize => 'Size';
@override
String get pdfInfoPages => 'Pages';
@override
String get pdfInfoUnknown => 'Known once it is opened';
@override
String get pdfDelete => 'Delete';
@override
String get pdfDeleteTitle => 'Delete PDF?';
@override
String pdfDeleteMessage(String title) {
return '\"$title\" is removed from this studyset, with how far it was read. Flashcards and notes made from it stay.';
}
@override
String get pdfDeleteConfirm => 'Delete';
@override
String get pdfDeleted => 'PDF deleted';
@override
String get deadlineSheetSet => 'Set a reading deadline';
@override
String get deadlineSheetEdit => 'Edit reading deadline';
@override
String get deadlineSheetHelp =>
'Reach a page by a day. Progress follows the furthest page you reach as you read.';
@override
String deadlineReachPageOf(int count) {
return 'Reach page (of $count)';
}
@override
String get deadlineReachPage => 'Reach page';
@override
String get deadlineDecrease => 'Decrease';
@override
String get deadlineIncrease => 'Increase';
@override
String get deadlineChapter => 'Or finish a chapter';
@override
String deadlineChapterEntry(String title, int page) {
return '$title · to p.$page';
}
@override
String get deadlineBy => 'By';
@override
String get deadlineStart => 'Start on';
@override
String get deadlineStartToday => 'Today';
@override
String get deadlineStartReset => 'Start today';
@override
String get deadlineStartHelp =>
'The daily pace counts from this day on. Until then the PDF waits under \"Upcoming\".';
@override
String get deadlineSave => 'Save deadline';
@override
String get deadlineRemove => 'Remove deadline';
@override
String get readerMissing =>
'This PDF isn\'t on this device yet. It opens once the device that added it has uploaded it and you\'re online.';
@override
String get readerUnreadable => 'This file can\'t be read as a PDF.';
@override
String get readerBack => 'Back to studyset';
@override
String get readerSearch => 'Search PDF';
@override
String get readerFitWidth => 'Fit width';
@override
String get readerFitHeight => 'Fit height';
@override
String get readerZoomIn => 'Zoom in';
@override
String get readerZoomOut => 'Zoom out';
@override
String get readerSetZoom => 'Set zoom';
@override
String get readerZoomTitle => 'Zoom';
@override
String get readerZoomApply => 'Apply';
@override
String get readerFullscreen => 'Fullscreen';
@override
String get readerExitFullscreen => 'Exit fullscreen';
@override
String get readerPreviousPage => 'Previous page';
@override
String get readerNextPage => 'Next page';
@override
String readerPageOf(int count) {
return '/ $count';
}
@override
String get readerFindHint => 'Find in PDF';
@override
String readerMatchOf(int current, int total) {
return '$current of $total';
}
@override
String get readerPreviousMatch => 'Previous match';
@override
String get readerNextMatch => 'Next match';
@override
String get readerCloseSearch => 'Close search';
@override
String get readerExpand => 'Expand';
@override
String get readerCollapse => 'Collapse';
@override
String get readerTabFlashcards => 'Flashcards';
@override
String get readerTabNotes => 'Notes';
@override
String get readerCardCreated => 'Flashcard created';
@override
String get readerCardDeleteTitle => 'Delete flashcard?';
@override
String get readerCardDeleteMessage =>
'The flashcard is deleted, with how it was studied.';
@override
String get readerCardDeleteConfirm => 'Delete';
@override
String get readerFrontMatter => 'Front matter';
@override
String readerPage(int page) {
return 'Page $page';
}
@override
String get readerByPage => 'Page';
@override
String get readerByChapter => 'Chapter';
@override
String get readerCardsEmpty => 'Select text to make flashcards in no time';
@override
String get readerCardsEmptyHint =>
'Select the lines that matter and send them to a question or an answer, or press \"Create flashcard\".';
@override
String get readerCreateCard => 'Create flashcard';
@override
String get readerEditCard => 'Edit flashcard';
@override
String get readerBackToCards => 'Back to flashcards';
@override
String readerReferencedPage(int page) {
return 'Referenced page: $page';
}
@override
String readerUseCurrentPage(int page) {
return 'Use current ($page)';
}
@override
String get readerQuestion => 'Question';
@override
String get readerAnswer => 'Answer';
@override
String get readerKeepOpen => 'Keep creation mode open';
@override
String get readerClear => 'Clear';
@override
String get readerCreate => 'Create';
@override
String get readerUntitledCard => 'Untitled flashcard';
@override
String get readerCardOptions => 'Card options';
@override
String get readerNoteSaved => 'Saved';
@override
String get readerNoteEditing => 'Editing…';
@override
String get readerNoteSaving => 'Saving…';
@override
String get readerNoteFailed => 'Save failed';
@override
String get readerToQuestion => 'Question';
@override
String get readerToAnswer => 'Answer';
@override
String get readerToNote => 'Notes';
@override
String get readerLineBreaksJoined => 'Line breaks joined (tap to keep)';
@override
String get readerLineBreaksKept => 'Line breaks kept (tap to join)';
@override
String get blockScreenshot => 'Screenshot of the page';
@override
String get readerCaptureCancel => 'Cancel screenshot';
@override
String get readerCaptureHint => 'Drag over the part of the page to keep';
@override
String get readerCaptureFailed =>
'The page couldn\'t be drawn. Try again once it has loaded.';
@override
String get readerScreenshotSaved => 'Screenshot saved';
@override
String get imageOpenPdfPage => 'Open the PDF at this page';
@override
String get homeWidgetDeadlines => 'Reading deadlines';
@override
String get deadlinesTitle => 'Reading deadlines';
@override
String get deadlinesCurrent => 'Current';
@override
String get deadlinesUpcoming => 'Upcoming';
@override
String deadlinesProgress(int furthest, int target, String when) {
return 'p.$furthest/$target · $when';
}
@override
String get deadlinePassedShort => 'deadline passed';
@override
String get deadlineDueTodayShort => 'due today';
@override
String deadlineDaysLeft(int days) {
String _temp0 = intl.Intl.pluralLogic(
days,
locale: localeName,
other: '$days days left',
one: '1 day left',
);
return '$_temp0';
}
@override
String deadlineToPageToday(int page) {
return 'to p.$page today';
}
@override
String deadlineStartsIn(int days) {
String _temp0 = intl.Intl.pluralLogic(
days,
locale: localeName,
other: '$days days',
one: '1 day',
);
return 'Starts in $_temp0';
}
@override
String get deadlinesPlaceholder => 'Appears when you have reading deadlines';
@override
String get deadlinesOpenTimeline => 'Open the timeline';
@override
String get timelineTitle => 'Reading timeline';
@override
String get timelineEmpty => 'No reading deadlines yet';
@override
String get timelineEmptyHint =>
'Set a reading deadline on a PDF to see it here.';
@override
String get timelineToday => 'Today';
@override
String timelinePages(int furthest, int target) {
return 'p.$furthest/$target';
}
@override
String chapterFinished(String chapter, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count cards',
one: 'card',
);
return 'Finished “$chapter” — study the $_temp0 you made?';
}
@override
String get chapterStudyNow => 'Study now';
@override
String get chapterDismiss => 'Dismiss';
@override
String get deadlineChapterLoading => 'Loading chapters…';
@override
String fileSizeKb(int size) {
return '$size KB';
}
@override
String fileSizeMb(String size) {
return '$size MB';
}
@override
String pdfPageShort(int page) {
return 'p. $page';
}
@override
String get ankiImportMenu => 'Import from Anki';

View file

@ -18,6 +18,8 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_runn
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/step_editor_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_editor_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/note/widget/note_editor_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/pdf_reader_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/reading_timeline_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/widget/settings_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_session_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_focus.dart';
@ -175,6 +177,18 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
state.pathParameters['stepId']!,
),
),
GoRoute(
path: RoutePaths.readingTimeline,
builder: (context, state) => const ReadingTimelineScreen(),
),
// A PDF, read in the whole window; at a page when asked.
GoRoute(
path: '/pdfs/:pdfId',
builder: (context, state) => PdfReaderScreen(
pdfId: state.pathParameters['pdfId']!,
page: int.tryParse(state.uri.queryParameters['page'] ?? ''),
),
),
GoRoute(
path: RoutePaths.settings,
builder: (context, state) => const SettingsScreen(),

View file

@ -27,4 +27,8 @@ abstract interface class AssetGateway() {
required int byteSize,
required ByteData payload,
});
/// Blanks the row [id]'s payload, then deletes the row: its file goes from
/// the server once nothing refers to it any more.
Future<void> delete(String id);
}

View file

@ -29,9 +29,19 @@ class AssetRepository(
_triggers = uploadTriggers.listen((_) => unawaited(uploadPending()));
}
/// The largest file the server takes.
/// The largest file the server takes, of any kind but a PDF.
static const maxBytes = 20 * 1024 * 1024;
/// The largest PDF the server takes (decided 2026-10-03).
static const maxPdfBytes = 50 * 1024 * 1024;
/// A PDF's media type.
static const pdfMediaType = 'application/pdf';
/// The largest file of [mimeType] the server takes.
static int maxBytesOf(String mimeType) =>
mimeType == pdfMediaType ? maxPdfBytes : maxBytes;
late final StreamSubscription<void> _triggers;
/// Adding one file at a time, so a file added twice at once is one row.
@ -46,13 +56,14 @@ class AssetRepository(
/// Adds [bytes], a file named [fileName] of [mimeType], and returns the id
/// of its asset row: the row of the same file if there is one, else a new
/// one. Throws an [ArgumentError] for an empty file or one over [maxBytes].
/// one. Throws an [ArgumentError] for an empty file or one too large for
/// its kind ([maxBytesOf]).
Future<String> add(
Uint8List bytes, {
required String fileName,
required String mimeType,
}) async {
if (bytes.isEmpty || bytes.length > maxBytes) {
if (bytes.isEmpty || bytes.length > maxBytesOf(mimeType)) {
throw ArgumentError.value(bytes.length, 'bytes', 'is out of range');
}
return await _adding.run(() async {
@ -183,6 +194,14 @@ class AssetRepository(
}
}
/// The size of the file [id] in bytes, from its row; null without one.
Future<int?> byteSizeOf(String id) async => (await _rows.byId(id))?.byteSize;
/// Deletes the row of the file [id], blanked first: the file goes from the
/// server once nothing refers to it, and stops taking the person's room.
/// Only for files no content refers to any more.
Future<void> delete(String id) => _rows.delete(id);
/// Stops uploading; an upload under way finishes first.
Future<void> dispose() async {
await _triggers.cancel();

View file

@ -2,6 +2,7 @@ import 'dart:typed_data';
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_gateway.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
@ -51,6 +52,24 @@ class SyncAssetGateway(
return asset.id!.uuid;
}
@override
Future<void> delete(String id) async {
final uuid = protocol.UuidValue.withValidation(id);
if (await protocol.Asset.db.findById(_db, uuid) case final asset?) {
// The name it came with is content (ADR 0002, rule 2).
await protocol.Asset.db.updateRow(
_db,
asset.copyWith(payload: redactedPayload),
columns: (t) => [t.payload],
);
await protocol.Asset.db.deleteWhere(
_db,
where: (t) => t.id.equals(uuid),
);
_sync.requestSync();
}
}
AssetRow? _row(protocol.Asset? asset) => asset == null
? null
: (

View file

@ -8,6 +8,13 @@
/// A sound: the person's file at `url` (`assetUrl`), played on a tap.
const audioBlockType = 'audio';
/// On the editor's own `image` block: the id of the PDF a screenshot was
/// taken from; missing for any other image.
const imagePdfKey = 'pdf';
/// On the editor's own `image` block: the page of that PDF, from 1.
const imagePageKey = 'page';
/// A boxed aside of a kind (a note, a tip, a warning…), holding blocks of
/// its own as children.
const calloutBlockType = 'callout';

View file

@ -0,0 +1,20 @@
/// A page of a PDF that content is tied to: a card made from it, a note
/// about it. The PDF is named by its row's id; pages count from 1.
typedef PdfPageRef = ({String pdfId, int page});
/// [ref] as content payloads store it: `{"pdf", "page"}`.
Map<String, Object?> pdfPageRefToJson(PdfPageRef ref) => {
'pdf': ref.pdfId,
'page': ref.page,
};
/// The page [json] names, as [pdfPageRefToJson] wrote it; null for null.
/// Throws a [FormatException] for anything else.
PdfPageRef? pdfPageRefFromJson(Object? json) => switch (json) {
null => null,
{'pdf': final String pdfId, 'page': final int page} when page > 0 => (
pdfId: pdfId,
page: page,
),
_ => throw const FormatException('A malformed page of a PDF.'),
};

View file

@ -30,6 +30,11 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/data/sync_path_a
import 'package:halkyon_learn_flutter/src/feature/note/data/note_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/sync_note_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_engine.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdfrx_engine.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/sync_pdf_source_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/sync_settings_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
@ -75,6 +80,9 @@ class Dependencies({
/// Plays the YouTube videos in content.
required final YoutubeVideos videos,
/// Opens and shows PDFs.
required final PdfEngine pdfEngine,
/// Reads Anki packages to import.
required final AnkiPackageReader ankiPackages,
final Future<void> Function()? _onDispose,
@ -101,6 +109,7 @@ class Dependencies({
// person's database has closed, so their folder can go. If it doesn't
// close, the app goes on after a while and the folder goes next time.
var previousClosed = Future<void>.value();
await PdfrxEngine.initialize();
final sounds = AudioplayersSoundPlayer();
return Dependencies(
auth: AuthRepository(authGateway),
@ -109,6 +118,7 @@ class Dependencies({
sounds: sounds,
files: FileSelectorFilePicker(),
videos: IframeYoutubeVideos(),
pdfEngine: PdfrxEngine(),
ankiPackages: IsolateAnkiPackageReader(),
openPerson: (person) async {
await previousClosed.timeout(
@ -181,16 +191,27 @@ Future<PersonDependencies> _openPerson(
);
final settings = SettingsRepository(SyncSettingsGateway(db, sync));
final reviews = ReviewRepository(SyncReviewGateway(db, sync));
final deletions = StudysetDeletions(studysets, cards, notes, paths);
final noteDeletions = NoteDeletions(notes);
final pathDeletions = LearningPathDeletions(paths);
final stepDeletions = StepDeletions(paths);
final assets = AssetRepository(
SyncAssetGateway(db, sync),
ServerpodAssetServerGateway(client),
DirectoryAssetFileCache(Directory(p.join(folder.path, 'files'))),
uploadTriggers: sync.synced,
);
final pdfSources = PdfSourceRepository(
SyncPdfSourceGateway(db, sync),
assets,
);
final deletions = StudysetDeletions(
studysets,
cards,
notes,
paths,
pdfSources,
);
final noteDeletions = NoteDeletions(notes);
final pathDeletions = LearningPathDeletions(paths);
final stepDeletions = StepDeletions(paths);
final pdfDeletions = PdfDeletions(pdfSources);
return PersonDependencies(
studysets: studysets,
@ -198,6 +219,7 @@ Future<PersonDependencies> _openPerson(
notes: notes,
paths: paths,
pathProgress: pathProgress,
pdfSources: pdfSources,
reviews: reviews,
tags: tags,
settings: settings,
@ -206,6 +228,7 @@ Future<PersonDependencies> _openPerson(
noteDeletions: noteDeletions,
pathDeletions: pathDeletions,
stepDeletions: stepDeletions,
pdfDeletions: pdfDeletions,
onDispose: () async {
try {
// Deletions first: those still waiting are dropped, those under way
@ -214,6 +237,7 @@ Future<PersonDependencies> _openPerson(
await noteDeletions.dispose();
await pathDeletions.dispose();
await stepDeletions.dispose();
await pdfDeletions.dispose();
await studysets.dispose();
await tags.dispose();
await settings.dispose();
@ -222,6 +246,7 @@ Future<PersonDependencies> _openPerson(
await notes.dispose();
await paths.dispose();
await pathProgress.dispose();
await pdfSources.dispose();
await assets.dispose();
await sync.dispose();
await db.close();

View file

@ -5,6 +5,8 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_pa
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
@ -32,6 +34,9 @@ class PersonDependencies({
/// Where the person stands in their learning paths.
required final PathProgressRepository pathProgress,
/// The person's PDFs and how far they have read them.
required final PdfSourceRepository pdfSources,
/// The person's reviews.
required final ReviewRepository reviews,
@ -41,7 +46,7 @@ class PersonDependencies({
/// The person's settings, which follow them to every device.
required final SettingsRepository settings,
/// The files of the person's content: images and sounds.
/// The files of the person's content: images, sounds and PDFs.
required final AssetRepository assets,
/// The studyset deletions that can still be undone.
@ -55,6 +60,9 @@ class PersonDependencies({
/// The deletions of paths' steps that can still be undone.
required final StepDeletions stepDeletions,
/// The PDF deletions that can still be undone.
required final PdfDeletions pdfDeletions,
final Future<void> Function()? _onDispose,
}) {
/// Closes what was opened for the person.

View file

@ -2,6 +2,7 @@ import 'dart:developer';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/asset/bloc/sound_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
@ -13,6 +14,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.d
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/editor/atomic_block.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -169,9 +171,22 @@ class _FileBlockState()
icon: LucideIcons.imageOff,
text: l10n.fileFromElsewhere,
),
(_FileKind.image, final id?) => _FileImage(
assetId: id,
maxWidth: (node.attributes[ImageBlockKeys.width] as num?)?.toDouble(),
(_FileKind.image, final id?) => Column(
children: [
_FileImage(
assetId: id,
maxWidth: (node.attributes[ImageBlockKeys.width] as num?)
?.toDouble(),
),
// A screenshot of a PDF leads back to its page.
if ((node.attributes[imagePdfKey], node.attributes[imagePageKey])
case (final String pdfId, final int page))
TextButton.icon(
icon: const Icon(LucideIcons.fileText, size: 16),
label: Text(l10n.pdfPageShort(page)),
onPressed: () => context.push(RoutePaths.pdf(pdfId, page: page)),
),
],
),
(_FileKind.sound, final id?) => _Sound(assetId: id),
};

View file

@ -16,6 +16,9 @@ abstract final class RoutePaths() {
/// Settings, over the tabs.
static const settings = '/settings';
/// The reading deadlines on a line of days, over the tabs.
static const readingTimeline = '/reading-timeline';
/// A studyset's screen, nested in the library tab: its back button leads
/// to the library.
static String studyset(String id) => '/library/studysets/$id';
@ -46,6 +49,14 @@ abstract final class RoutePaths() {
static String note(String studysetId, String noteId) =>
'/studysets/$studysetId/notes/$noteId';
/// A PDF, to read, over the tabs; at [page] when given, else where reading
/// left off. Cards and screenshots tied to a page name only the PDF, which
/// knows its studyset.
static String pdf(String pdfId, {int? page}) => Uri(
path: '/pdfs/$pdfId',
queryParameters: page == null ? null : {'page': '$page'},
).toString();
/// A learning path of a studyset, to work through, over the tabs.
static String path(String studysetId, String pathId) =>
'/studysets/$studysetId/paths/$pathId';

View file

@ -1,6 +1,7 @@
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/core/content/pdf_page_ref.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
@ -12,9 +13,11 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
// map), each left out while empty; schema 1 reads as a front/back card.
// Schema 3 added where a card came from (`origin`, e.g. "anki"), in its first
// revision only and beside its content; left out for a card written here.
// Schema 4 added the page of a PDF the card was made from (`source`, see
// pdf_page_ref.dart), left out for a card of its own.
/// The schema this app writes.
const cardSchemaVersion = 3;
const cardSchemaVersion = 4;
/// Encodes a card's content into a revision's payload; [origin] names where
/// an imported card came from, in its first revision.
@ -45,6 +48,7 @@ ByteData encodeCardPayload(CardContent content, {String? origin}) =>
],
},
'map': ?content.map,
if (content.source case final source?) 'source': pdfPageRefToJson(source),
});
/// Where the card of a revision's [payload] came from: null for one written
@ -80,6 +84,7 @@ CardContent decodeCardPayload(ByteData payload) {
meanings: _strings(json['meanings'], 'meanings'),
matching: _matching(json['matching']),
map: _optional<String>(json, 'map'),
source: pdfPageRefFromJson(json['source']),
);
}
throw const FormatException('Not a card payload.');

View file

@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/core/content/pdf_page_ref.dart';
import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
@ -46,6 +47,9 @@ enum CardField() {
/// The map a multiple-choice card is drawn on.
map,
/// The page of a PDF the card was made from.
source,
}
/// One answer of a multiple-choice card.
@ -120,6 +124,9 @@ final class const CardContent({
/// The id of the map a multiple-choice card is drawn on.
final String? map,
/// The page of a PDF the card was made from; kept whatever its type.
final PdfPageRef? source,
}) with Equatable implements MergeableContent<CardField, CardContent> {
/// A front/back card with nothing on either side.
factory empty() =>
@ -135,6 +142,7 @@ final class const CardContent({
CardField.meanings => meanings,
CardField.matching => matching,
CardField.map => map,
CardField.source => source,
};
@override
@ -147,6 +155,7 @@ final class const CardContent({
meanings: field == CardField.meanings ? value! as List<String> : meanings,
matching: field == CardField.matching ? value! as Matching : matching,
map: field == CardField.map ? value as String? : map,
source: field == CardField.source ? value as PdfPageRef? : source,
);
/// This content with some fields replaced; [withField] clears the map.
@ -159,6 +168,7 @@ final class const CardContent({
List<String>? meanings,
Matching? matching,
String? map,
PdfPageRef? source,
}) => CardContent(
type: type ?? this.type,
front: front ?? this.front,
@ -168,9 +178,11 @@ final class const CardContent({
meanings: meanings ?? this.meanings,
matching: matching ?? this.matching,
map: map ?? this.map,
source: source ?? this.source,
);
/// The fields [type] uses; the others stay empty.
/// The fields [type] uses; the others stay empty. The page it was made
/// from isn't one of them: every type keeps it.
Set<CardField> get fields => switch (type) {
CardType.frontBack => const {CardField.front, CardField.back},
CardType.multipleChoice when map != null => const {
@ -196,6 +208,7 @@ final class const CardContent({
type: type,
front: RichContent.empty(),
back: RichContent.empty(),
source: source,
);
for (final field in kept) {
content = content.withField(field, this[field]);
@ -276,6 +289,7 @@ final class const CardContent({
meanings,
matching,
map,
source,
];
}

View file

@ -487,6 +487,7 @@ class const _Conflicts({required final CardDraft draft})
CardField.meanings => l10n.vocabularyMeanings,
CardField.matching => l10n.composerKindMatching,
CardField.map => l10n.composerKindMap,
CardField.source => l10n.pdfPageField,
};
}
@ -512,6 +513,9 @@ class const _Version({required final Object? version}) extends StatelessWidget {
].join('\n'),
),
final CardType type => Text(type.name),
(pdfId: _, page: final int page) => Text(
AppLocalizations.of(context).pdfPageShort(page),
),
_ => Text('${version ?? ''}'),
};
}

View file

@ -5,6 +5,9 @@ enum HomeWidget() {
/// The study goal.
goal,
/// The reading deadlines of PDFs; it shows only while there are some.
deadlines,
/// The week overview.
week,

View file

@ -11,6 +11,7 @@ import 'package:halkyon_learn_flutter/src/feature/home/widget/activity_heatmap.d
import 'package:halkyon_learn_flutter/src/feature/home/widget/goal_card.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/streak.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/week_overview.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/reading_deadlines_card.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -120,6 +121,7 @@ class _HomeState() extends State<_Home> {
progress: state.goalProgress,
onChanged: (goal) => _send(HomeEventGoalSet(goal)),
),
HomeWidget.deadlines => ReadingDeadlinesCard(placeholder: _arranging),
HomeWidget.week => SectionCard(
padding: const EdgeInsets.fromLTRB(4, 12, 4, 12),
child: WeekOverview(
@ -209,6 +211,7 @@ class const _Arranging({
final l10n = AppLocalizations.of(context);
String label(HomeWidget widget) => switch (widget) {
HomeWidget.goal => l10n.homeWidgetGoal,
HomeWidget.deadlines => l10n.homeWidgetDeadlines,
HomeWidget.week => l10n.homeWidgetWeek,
HomeWidget.activity => l10n.homeWidgetActivity,
};

View file

@ -1,20 +1,24 @@
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/core/content/pdf_page_ref.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
// A note revision's content as its synced row stores it (see
// core/content/payload). Schema 1: the title and the body.
// core/content/payload). Schema 1: the title and the body. Schema 2 added the
// page of a PDF the note is about (`source`, see pdf_page_ref.dart), left out
// for a note of its own.
/// The schema this app writes.
const noteSchemaVersion = 1;
const noteSchemaVersion = 2;
/// Encodes a note's content into a revision's payload.
ByteData encodeNotePayload(NoteContent content) =>
encodePayload(noteSchemaVersion, {
'title': content.title,
'body': content.body.toJson(),
if (content.source case final source?) 'source': pdfPageRefToJson(source),
});
/// Decodes a note revision's payload.
@ -27,7 +31,11 @@ NoteContent decodeNotePayload(ByteData payload) {
'title': final String title,
'body': final Map<String, Object?> body,
}) {
return NoteContent(title: title, body: RichContent.fromJson(body));
return NoteContent(
title: title,
body: RichContent.fromJson(body),
source: pdfPageRefFromJson(json['source']),
);
}
throw const FormatException('Not a note payload.');
}

Some files were not shown because too many files have changed in this diff Show more