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
15 changed files with 34926 additions and 58 deletions

View file

@ -125,6 +125,20 @@ void main() {
expect(content, CardContent(front: text('猫'), back: text('cat')));
});
test('a card tied to a page of a PDF keeps it, whatever its type', () {
final content = CardContent(
front: text('猫'),
back: text('cat'),
source: (pdfId: 'grammar-pdf', page: 12),
);
final decoded = decodeCardPayload(encodeCardPayload(content));
expect(decoded, content);
expect(decoded.source, (pdfId: 'grammar-pdf', page: 12));
expect(content.normalized.source, (pdfId: 'grammar-pdf', page: 12));
});
test('refuses a type this app does not know', () {
expect(
() => decodeCardPayload(

View file

@ -201,8 +201,13 @@ void main() {
..add(
const HomeEventLayoutChanged(
HomeLayout(
order: [HomeWidget.week, HomeWidget.goal, HomeWidget.activity],
hidden: {HomeWidget.activity},
order: [
HomeWidget.week,
HomeWidget.goal,
HomeWidget.activity,
HomeWidget.deadlines,
],
hidden: {HomeWidget.activity, HomeWidget.deadlines},
),
),
);

View file

@ -26,6 +26,26 @@ void main() {
expect(decodeNotePayload(encodeNotePayload(content)).title, '');
});
test('a note tied to a page of a PDF keeps it; one of the first schema is '
'tied to none', () {
final content = NoteContent(
body: RichContent.plain('は'),
source: (pdfId: 'grammar-pdf', page: 12),
);
expect(decodeNotePayload(encodeNotePayload(content)), content);
expect(
decodeNotePayload(
bytes({
'schemaVersion': 1,
'title': '',
'body': RichContent.empty().toJson(),
}),
).source,
isNull,
);
});
test('refuses a schema newer than this app knows', () {
expect(
() => decodeNotePayload(

View file

@ -0,0 +1,207 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/bloc/pdf_list_bloc.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 '../../../support/fake_asset_gateway.dart';
import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_note_gateway.dart';
import '../../../support/fake_pdf_source_gateway.dart';
import '../../../support/memory_asset_file_cache.dart';
Uint8List pdf(String text) => Uint8List.fromList(utf8.encode('%PDF-1.7 $text'));
void main() {
late AssetRepository assets;
late PdfSourceRepository pdfs;
late PdfDeletions deletions;
late CardRepository cards;
late NoteRepository notes;
late PdfListBloc bloc;
setUp(() {
assets = AssetRepository(
FakeAssetGateway(),
FakeAssetServerGateway(),
MemoryAssetFileCache(),
);
pdfs = PdfSourceRepository(FakePdfSourceGateway(), assets);
deletions = PdfDeletions(pdfs, undoWindow: const Duration(hours: 1));
cards = CardRepository(FakeCardGateway());
notes = NoteRepository(FakeNoteGateway());
});
tearDown(() async {
await bloc.close();
await deletions.dispose();
await pdfs.dispose();
await cards.dispose();
await notes.dispose();
await assets.dispose();
});
void start() => bloc = PdfListBloc(
pdfs,
deletions,
cards,
notes,
assets,
'japanese',
)..add(const PdfListEventStarted());
Future<PdfListState> until(bool Function(PdfListState) test) =>
test(bloc.state)
? Future.value(bloc.state)
: bloc.stream.firstWhere(test);
Future<String> add(String name, [String text = '']) async =>
(await pdfs.import(
'japanese',
bytes: pdf(text.isEmpty ? name : text),
fileName: '$name.pdf',
) as PdfImported).id;
test("lists the studyset's PDFs with their cards, notes, size and how far "
'they are read', () async {
final grammar = await add('Grammar', 'a longer grammar file');
await add('Kana');
await pdfs.reached(grammar, 7);
await cards.create(
'japanese',
CardContent(
front: RichContent.plain('は'),
back: RichContent.plain('topic'),
source: (pdfId: grammar, page: 3),
),
);
await notes.create(
'japanese',
NoteContent(
body: RichContent.plain('p. 3'),
source: (pdfId: grammar, page: 3),
),
);
start();
final state = await until(
(s) => s.rows.any(
(row) =>
row.cards == 1 &&
row.notes == 1 &&
row.byteSize != null &&
row.furthestPage != null,
),
);
// The newest first.
expect(
[for (final row in state.rows) row.source.content.title],
[
'Kana',
'Grammar',
],
);
final row = state.rows.last;
expect(row.cards, 1);
expect(row.notes, 1);
expect(row.furthestPage, 7);
expect(row.byteSize, pdf('a longer grammar file').length);
});
test('sorts by name, age or size, as chosen', () async {
await add('b', 'b');
await add('a', 'a much larger file');
await add('c', 'c');
start();
await until(
(s) => s.rows.length == 3 && s.rows.every((r) => r.byteSize != null),
);
List<String> titles() => [
for (final row in bloc.state.rows) row.source.content.title,
];
bloc.add(const PdfListEventSorted(PdfSort.name));
await until((s) => s.sort == PdfSort.name);
expect(titles(), ['a', 'b', 'c']);
bloc.add(const PdfListEventSorted(PdfSort.oldest));
await until((s) => s.sort == PdfSort.oldest);
expect(titles(), ['b', 'a', 'c']);
bloc.add(const PdfListEventSorted(PdfSort.largest));
await until((s) => s.sort == PdfSort.largest);
expect(titles().first, 'a');
});
test('leaves out a PDF whose deletion can still be undone', () async {
final kana = await add('Kana');
start();
await until((s) => s.rows.length == 1);
deletions.delete(kana);
await until((s) => s.rows.isEmpty);
deletions.undo(kana);
await until((s) => s.rows.length == 1);
});
group('importing', () {
test(
'says what came of it, a duplicate offered to be added anyway',
() async {
await add('Grammar', 'grammar');
start();
await until((s) => s.rows.length == 1);
bloc.add(
PdfListEventImportRequested(bytes: pdf('grammar'), fileName: 'g.pdf'),
);
final duplicate = await until((s) => s.importOutcome != null);
expect(duplicate.importOutcome!.result, isA<PdfAlreadyThere>());
expect(duplicate.importOutcome!.title, 'g');
bloc.add(const PdfListEventImportedAgain());
final again = await until(
(s) => s.importOutcome?.result is PdfImported && s.rows.length == 2,
);
expect(again.importing, isFalse);
},
);
test('refuses what is no PDF', () async {
start();
await until((s) => s.loaded);
bloc.add(
PdfListEventImportRequested(
bytes: Uint8List.fromList(utf8.encode('text')),
fileName: 'notes.pdf',
),
);
final state = await until((s) => s.importOutcome != null);
expect(state.importOutcome!.result, const PdfRefused(PdfRefusal.notPdf));
expect(state.rows, isEmpty);
});
});
test('renames, sets a deadline, marks as read and starts afresh', () async {
final kana = await add('Kana');
await pdfs.setPageCount(kana, 40);
start();
await until((s) => s.rows.length == 1);
bloc.add(PdfListEventRenamed(kana, 'Hiragana'));
await until((s) => s.rows.single.source.content.title == 'Hiragana');
bloc.add(PdfListEventFinished(kana));
await until((s) => s.rows.single.furthestPage == 40);
bloc.add(PdfListEventReadingReset(kana));
await until((s) => s.rows.single.furthestPage == null);
});
}

View file

@ -0,0 +1,111 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/bloc/pdf_reader_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import '../../../support/fake_asset_gateway.dart';
import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/fake_pdf_engine.dart';
import '../../../support/fake_pdf_source_gateway.dart';
import '../../../support/memory_asset_file_cache.dart';
Uint8List pdf(String text) => Uint8List.fromList(utf8.encode('%PDF-1.7 $text'));
void main() {
late MemoryAssetFileCache files;
late AssetRepository assets;
late PdfSourceRepository pdfs;
late FakePdfEngine engine;
late PdfReaderBloc bloc;
setUp(() {
files = MemoryAssetFileCache();
assets = AssetRepository(
FakeAssetGateway(),
FakeAssetServerGateway(),
files,
);
pdfs = PdfSourceRepository(FakePdfSourceGateway(), assets);
engine = FakePdfEngine();
});
tearDown(() async {
await bloc.close();
await pdfs.dispose();
await assets.dispose();
});
Future<String> add(String text) async => (await pdfs.import(
'japanese',
bytes: pdf(text),
fileName: 'g.pdf',
) as PdfImported).id;
Future<PdfReaderState> open(String id, {int? page}) {
bloc = PdfReaderBloc(pdfs, assets, engine, id, page: page)
..add(const PdfReaderEventStarted());
return bloc.stream.firstWhere((s) => s is! PdfReaderStateLoading);
}
test('opens the PDF, notes its page count and chapters, and starts on '
'its first page', () async {
final id = await add('pages:40 chapter:1:Kana; chapter:12:Particles;');
final ready = await open(id) as PdfReaderStateReady;
expect(ready.document.pageCount, 40);
expect(ready.chapters, [
(title: 'Kana', page: 1),
(title: 'Particles', page: 12),
]);
expect(ready.startPage, 1);
expect((await pdfs.byId(id))!.content.pageCount, 40);
});
test('resumes at the furthest page read, unless a page is asked for or '
'it was read to the end', () async {
final id = await add('pages:40');
await pdfs.reached(id, 17);
expect((await open(id) as PdfReaderStateReady).startPage, 17);
await bloc.close();
expect((await open(id, page: 3) as PdfReaderStateReady).startPage, 3);
await bloc.close();
await pdfs.reached(id, 40);
expect((await open(id) as PdfReaderStateReady).startPage, 1);
});
test('records each page shown, the furthest kept', () async {
final id = await add('pages:40');
await open(id);
bloc
..add(const PdfReaderEventPageShown(5))
..add(const PdfReaderEventPageShown(2));
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
expect(await pdfs.furthestPages(), {id: 5});
});
test("says when the file isn't on this device, or can't be read, or the "
'PDF is gone', () async {
final id = await add('pages:4');
files.files.clear();
expect(await open(id), const PdfReaderStateMissing());
await bloc.close();
final broken = (await pdfs.import(
'japanese',
bytes: pdf('fine'),
fileName: 'b.pdf',
) as PdfImported).id;
engine.failNext = true;
expect(await open(broken), const PdfReaderStateUnreadable());
await bloc.close();
expect(await open('nowhere'), const PdfReaderStateGone());
});
}

View file

@ -0,0 +1,109 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
void main() {
ByteData bytes(Object json) =>
ByteData.sublistView(utf8.encode(jsonEncode(json)));
const content = PdfSourceContent(
title: '日本語の文法',
fileName: '日本語の文法.pdf',
assetId: 'file-1',
);
test('a PDF payload survives a round trip, Japanese included', () {
expect(decodePdfSourcePayload(encodePdfSourcePayload(content)), content);
});
test('keeps the page count and a deadline once there are some', () {
final full = content
.withPageCount(240)
.withDeadline(
const ReadingDeadline(
targetPage: 120,
due: (year: 2026, month: 10, day: 31),
start: (year: 2026, month: 10, day: 5),
),
);
expect(decodePdfSourcePayload(encodePdfSourcePayload(full)), full);
expect(
decodePayload(
encodePdfSourcePayload(full),
newestSchema: pdfSourceSchemaVersion,
)['deadline'],
{'targetPage': 120, 'due': '2026-10-31', 'start': '2026-10-05'},
);
});
test('refuses a schema newer than this app knows', () {
expect(
() => decodePdfSourcePayload(
bytes({
'schemaVersion': pdfSourceSchemaVersion + 1,
'title': 'x',
'fileName': 'x.pdf',
'asset': 'a',
}),
),
throwsFormatException,
);
});
test('refuses what is not a PDF payload, a blanked one included', () {
expect(
() => decodePdfSourcePayload(redactedPayload),
throwsFormatException,
);
for (final json in [
{'schemaVersion': 1, 'title': 'x'},
{
'schemaVersion': 1,
'title': 'x',
'fileName': 'x.pdf',
'asset': 'a',
'pageCount': 0,
},
{
'schemaVersion': 1,
'title': 'x',
'fileName': 'x.pdf',
'asset': 'a',
'deadline': {'targetPage': 3, 'due': 'soon'},
},
]) {
expect(
() => decodePdfSourcePayload(bytes(json)),
throwsFormatException,
reason: '$json',
);
}
});
test('titles a PDF by its file name without .pdf', () {
expect(titleFromFileName('Genki I.PDF'), 'Genki I');
expect(titleFromFileName(' notes '), 'notes');
});
test('counts days between calendar days, across months and years', () {
expect(
daysBetween(
(year: 2026, month: 12, day: 30),
(year: 2027, month: 1, day: 2),
),
3,
);
expect(
daysBetween(
(year: 2026, month: 10, day: 5),
(year: 2026, month: 10, day: 3),
),
-2,
);
});
}

View file

@ -0,0 +1,231 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import '../../../support/fake_asset_gateway.dart';
import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/fake_pdf_source_gateway.dart';
import '../../../support/memory_asset_file_cache.dart';
/// A PDF's bytes: its signature, then [text].
Uint8List pdf(String text) => Uint8List.fromList(utf8.encode('%PDF-1.7 $text'));
void main() {
late FakePdfSourceGateway gateway;
late FakeAssetGateway assetGateway;
late AssetRepository assets;
late PdfSourceRepository pdfs;
setUp(() {
gateway = FakePdfSourceGateway();
assetGateway = FakeAssetGateway();
assets = AssetRepository(
assetGateway,
FakeAssetServerGateway(),
MemoryAssetFileCache(),
);
pdfs = PdfSourceRepository(gateway, assets);
});
tearDown(() async {
await pdfs.dispose();
await assets.dispose();
});
group('importing', () {
test("adds a PDF titled by its file's name, its file kept as the "
"person's", () async {
final added = await pdfs.import(
'japanese',
bytes: pdf('grammar'),
fileName: 'Grammar Guide.pdf',
);
final source = (await pdfs.sourcesOf('japanese')).single;
expect(added, PdfImported(source.id));
expect(source.content.title, 'Grammar Guide');
expect(source.content.fileName, 'Grammar Guide.pdf');
expect(source.content.pageCount, isNull);
final asset = assetGateway.rows.single;
expect(source.content.assetId, asset.id);
expect(asset.mimeType, 'application/pdf');
});
test('refuses what is no PDF, and a PDF over 50 MB', () async {
expect(
await pdfs.import(
'japanese',
bytes: Uint8List.fromList(utf8.encode('not a pdf')),
fileName: 'notes.pdf',
),
const PdfRefused(PdfRefusal.notPdf),
);
final huge = Uint8List(PdfSourceRepository.maxBytes + 1)
..setAll(0, utf8.encode('%PDF-'));
expect(
await pdfs.import('japanese', bytes: huge, fileName: 'huge.pdf'),
const PdfRefused(PdfRefusal.tooLarge),
);
expect(await pdfs.sourcesOf('japanese'), isEmpty);
expect(assetGateway.rows, isEmpty);
});
test('says when the studyset holds the same file already, and adds it '
'again only when asked to', () async {
final first = await pdfs.import(
'japanese',
bytes: pdf('grammar'),
fileName: 'a.pdf',
) as PdfImported;
expect(
await pdfs.import('japanese', bytes: pdf('grammar'), fileName: 'b.pdf'),
PdfAlreadyThere(first.id),
);
expect(
await pdfs.import(
'japanese',
bytes: pdf('grammar'),
fileName: 'b.pdf',
evenIfThere: true,
),
isA<PdfImported>(),
);
// Another studyset may hold it too, from the same file.
expect(
await pdfs.import('korean', bytes: pdf('grammar'), fileName: 'a.pdf'),
isA<PdfImported>(),
);
expect(await pdfs.sourcesOf('japanese'), hasLength(2));
expect(assetGateway.rows, hasLength(1));
});
});
test('keeps a new title, the page count and a deadline', () async {
final id = (await pdfs.import(
'japanese',
bytes: pdf('g'),
fileName: 'g.pdf',
) as PdfImported).id;
const deadline = ReadingDeadline(
targetPage: 80,
due: (year: 2026, month: 10, day: 31),
);
await pdfs.rename(id, ' Tae Kim ');
await pdfs.setPageCount(id, 240);
await pdfs.setDeadline(id, deadline);
final content = (await pdfs.byId(id))!.content;
expect(content.title, 'Tae Kim');
expect(content.pageCount, 240);
expect(content.deadline, deadline);
await pdfs.setDeadline(id, null);
expect((await pdfs.byId(id))!.content.deadline, isNull);
});
test("leaves out a PDF it can't read, and counts the others by "
'studyset', () async {
await pdfs.import('japanese', bytes: pdf('a'), fileName: 'a.pdf');
await pdfs.import('japanese', bytes: pdf('b'), fileName: 'b.pdf');
await pdfs.import('korean', bytes: pdf('c'), fileName: 'c.pdf');
gateway.receiveRemote(
id: 'pdf-9999',
studysetId: 'japanese',
payload: encodePayload(pdfSourceSchemaVersion + 1, {}),
);
expect(await pdfs.sourcesOf('japanese'), hasLength(2));
expect(await pdfs.countsByStudyset(), {'japanese': 2, 'korean': 1});
});
group('deleting', () {
test('blanks the PDF before it goes, with its reading, and its file '
'unless another PDF has it', () async {
final a = (await pdfs.import(
'japanese',
bytes: pdf('a'),
fileName: 'a.pdf',
) as PdfImported).id;
final copy = (await pdfs.import(
'korean',
bytes: pdf('a'),
fileName: 'a.pdf',
) as PdfImported).id;
final b = (await pdfs.import(
'japanese',
bytes: pdf('b'),
fileName: 'b.pdf',
) as PdfImported).id;
await pdfs.reached(a, 3);
await pdfs.delete(a);
expect(gateway.deletions, ['blank $a', 'delete $a']);
expect(await pdfs.furthestPages(), isEmpty);
// The copy in Korean still has the file.
expect(assetGateway.rows, hasLength(2));
await pdfs.delete(copy);
await pdfs.delete(b);
expect(assetGateway.rows, isEmpty);
expect(assetGateway.deleted, hasLength(2));
});
test('every PDF of a studyset goes before it does', () async {
await pdfs.import('japanese', bytes: pdf('a'), fileName: 'a.pdf');
await pdfs.import('japanese', bytes: pdf('b'), fileName: 'b.pdf');
await pdfs.import('korean', bytes: pdf('c'), fileName: 'c.pdf');
await pdfs.deleteAllOf('japanese');
expect(await pdfs.sourcesOf('japanese'), isEmpty);
expect(await pdfs.sourcesOf('korean'), hasLength(1));
});
});
group('reading', () {
late String id;
setUp(() async {
id = (await pdfs.import(
'japanese',
bytes: pdf('a'),
fileName: 'a.pdf',
) as PdfImported).id;
await pdfs.setPageCount(id, 100);
});
test('remembers the furthest page reached, writing only when it '
'grows', () async {
await withClock(Clock.fixed(DateTime.utc(2026, 10, 3, 9)), () async {
await pdfs.reached(id, 5);
await pdfs.reached(id, 3);
await pdfs.reached(id, 5);
await pdfs.reached(id, 9);
});
expect(await pdfs.furthestPages(), {id: 9});
expect(gateway.readingRows.map((row) => row.page), [5, 9]);
});
test('a fresh start forgets the pages read; finishing reaches the '
'last', () async {
await pdfs.reached(id, 40);
await pdfs.resetReading(id);
expect(await pdfs.furthestPages(), isEmpty);
await pdfs.reached(id, 2);
expect(await pdfs.furthestPages(), {id: 2});
await pdfs.finish(id);
expect(await pdfs.furthestPages(), {id: 100});
});
});
}

View file

@ -0,0 +1,65 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/deadline_status.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
void main() {
const today = (year: 2026, month: 10, day: 3);
DeadlineStatus status({
int target = 100,
CalendarDay due = (year: 2026, month: 10, day: 12),
CalendarDay? start,
int furthest = 0,
}) => DeadlineStatus.of(
ReadingDeadline(targetPage: target, due: due, start: start),
furthestPage: furthest,
today: today,
);
test('spreads the pages left over the days left, today and the due day '
'included', () {
final ten = status(furthest: 40);
expect(ten.pagesLeft, 60);
expect(ten.daysLeft, 10);
expect(ten.pagesPerDay, 6);
expect(ten.pageToReachToday, 46);
expect(ten.fraction, closeTo(0.4, 1e-9));
expect(ten.state, DeadlineState.onTrack);
});
test('rounds the pace up, so the deadline is met', () {
expect(status(target: 11, due: today).pagesPerDay, 11);
expect(
status(target: 11, due: (year: 2026, month: 10, day: 4)).pagesPerDay,
6,
);
});
test('is met once the target page is reached, whatever the day', () {
final met = status(furthest: 100, due: (year: 2026, month: 9, day: 1));
expect(met.state, DeadlineState.met);
expect(met.pagesLeft, 0);
expect(met.fraction, 1);
});
test('is due today on its last day, and overdue after it', () {
expect(status(due: today, furthest: 90).state, DeadlineState.dueToday);
expect(status(due: today, furthest: 90).pagesPerDay, 10);
final passed = status(due: (year: 2026, month: 10, day: 1), furthest: 90);
expect(passed.state, DeadlineState.overdue);
expect(passed.pagesLeft, 10);
expect(passed.pagesPerDay, 10);
});
test('is upcoming before its start, the pace counted from the start', () {
final upcoming = status(start: (year: 2026, month: 10, day: 8));
expect(upcoming.state, DeadlineState.upcoming);
expect(upcoming.daysUntilStart, 5);
expect(upcoming.pagesPerDay, 20);
// Nothing to read yet today.
expect(upcoming.pageToReachToday, 0);
});
}

View file

@ -0,0 +1,85 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/deadline_status.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/reading_deadlines.dart';
void main() {
const today = (year: 2026, month: 10, day: 3);
PdfSource pdf(
String id, {
String studysetId = 'japanese',
int target = 100,
CalendarDay due = (year: 2026, month: 10, day: 20),
CalendarDay? start,
bool deadline = true,
}) => PdfSource(
id: id,
studysetId: studysetId,
content: PdfSourceContent(
title: id,
fileName: '$id.pdf',
assetId: 'a-$id',
pageCount: 200,
deadline: deadline
? ReadingDeadline(targetPage: target, due: due, start: start)
: null,
),
);
test('lists running deadlines, the fewest days left first, and upcoming '
'ones, the soonest start first; met ones and archived studysets '
'left out', () {
final deadlines = readingDeadlinesOf(
[
pdf('later'),
pdf('soon', due: (year: 2026, month: 10, day: 5)),
pdf('met', target: 10),
pdf('none', deadline: false),
pdf('archived', studysetId: 'old'),
pdf(
'next month',
start: (year: 2026, month: 11, day: 1),
due: (year: 2026, month: 11, day: 30),
),
pdf(
'next week',
start: (year: 2026, month: 10, day: 10),
due: (year: 2026, month: 11, day: 30),
),
],
furthest: {'met': 10, 'soon': 50},
today: today,
archived: {'old'},
);
expect(
[for (final (:pdf, status: _) in deadlines.current) pdf.id],
[
'soon',
'later',
],
);
expect(deadlines.current.first.status.state, DeadlineState.onTrack);
expect(
[for (final (:pdf, status: _) in deadlines.upcoming) pdf.id],
[
'next week',
'next month',
],
);
expect(deadlines.isEmpty, isFalse);
});
test('is empty without any running or upcoming deadline', () {
expect(
readingDeadlinesOf(
[pdf('met', target: 1)],
furthest: {'met': 1},
today: today,
archived: const {},
).isEmpty,
isTrue,
);
});
}

View file

@ -0,0 +1,270 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:material_ui/material_ui.dart';
import '../../../support/fake_asset_gateway.dart';
import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/fake_file_picker.dart';
import '../../../support/fake_pdf_source_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/memory_asset_file_cache.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
Uint8List pdf(String text) => Uint8List.fromList(utf8.encode('%PDF-1.7 $text'));
void main() {
late FakeStudysetGateway studysets;
late FakePdfSourceGateway pdfGateway;
late FakeAssetGateway assetGateway;
late FakeFilePicker files;
late MemoryAssetFileCache cache;
setUp(() {
studysets = FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
);
pdfGateway = FakePdfSourceGateway();
assetGateway = FakeAssetGateway();
files = FakeFilePicker();
cache = MemoryAssetFileCache();
});
/// A repository of the test's own on the app's fake gateways, to store
/// PDFs before the app reads them.
PdfSourceRepository repository() {
final assets = AssetRepository(
assetGateway,
FakeAssetServerGateway(),
cache,
);
final pdfs = PdfSourceRepository(pdfGateway, assets);
addTearDown(() async {
await pdfs.dispose();
await assets.dispose();
});
return pdfs;
}
Future<void> showApp(WidgetTester tester) async {
tester.view.physicalSize = const Size(1200, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
pdfGateway: pdfGateway,
assetGateway: assetGateway,
assetFiles: cache,
files: files,
),
location: RoutePaths.studyset('japanese'),
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
/// A menu item acts in the frame after its menu closes.
Future<void> choose(WidgetTester tester, String item) async {
await tester.tap(find.text(item));
await tester.pump();
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
Future<void> upload(WidgetTester tester, String name, Uint8List bytes) async {
files.next = (name: name, bytes: bytes);
await tester.tap(find.text('Add material'));
await tester.pumpAndSettle();
await choose(tester, 'Upload a PDF');
}
int shownTab(WidgetTester tester) =>
tester.widget<TabBar>(find.byType(TabBar)).controller!.index;
testWidgets('a PDF uploaded from Add material shows in Files, titled by '
'its name', (tester) async {
await showApp(tester);
await upload(tester, 'Grammar Guide.pdf', pdf('grammar'));
expect(files.asked.single, ['pdf']);
expect(find.text('Added "Grammar Guide".'), findsOneWidget);
expect(shownTab(tester), 3);
expect(find.text('Grammar Guide'), findsOneWidget);
});
testWidgets('a file the studyset holds already asks first; a copy is added '
'only when asked for', (tester) async {
await repository().import(
'japanese',
bytes: pdf('grammar'),
fileName: 'Grammar.pdf',
);
await showApp(tester);
await upload(tester, 'Copy.pdf', pdf('grammar'));
expect(find.text('PDF already here'), findsOneWidget);
await tester.tap(find.text('Keep the one there'));
await tester.pumpAndSettle();
expect(await pdfGateway.sources(), hasLength(1));
await upload(tester, 'Copy.pdf', pdf('grammar'));
await tester.tap(find.text('Add a copy'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(await pdfGateway.sources(), hasLength(2));
expect(find.text('Copy'), findsOneWidget);
});
testWidgets("a file that isn't a PDF is refused, and says so", (
tester,
) async {
await showApp(tester);
await upload(tester, 'notes.pdf', Uint8List.fromList(utf8.encode('text')));
expect(find.text("This file isn't a PDF."), findsOneWidget);
expect(await pdfGateway.sources(), isEmpty);
});
testWidgets('a row shows its pages, how far it is read and its deadline; '
'a studyset of PDFs only opens on them', (tester) async {
final pdfs = repository();
final id = (await pdfs.import(
'japanese',
bytes: pdf('grammar'),
fileName: 'Grammar.pdf',
) as PdfImported).id;
await pdfs.setPageCount(id, 240);
await pdfs.reached(id, 60);
await pdfs.setDeadline(
id,
const ReadingDeadline(
targetPage: 120,
due: (year: 2026, month: 10, day: 12),
),
);
await withClock(Clock.fixed(DateTime(2026, 10, 3, 9)), () async {
await showApp(tester);
});
expect(shownTab(tester), 3);
expect(find.textContaining('240 pages'), findsOneWidget);
expect(find.text('25% read'), findsOneWidget);
expect(find.text('6/day to reach page 120 in 10 days'), findsOneWidget);
});
testWidgets('sorts by name from the Sort menu', (tester) async {
final pdfs = repository();
for (final name in ['b', 'c', 'a']) {
await pdfs.import('japanese', bytes: pdf(name), fileName: '$name.pdf');
}
await showApp(tester);
double top(String title) => tester.getTopLeft(find.text(title)).dy;
expect(top('a'), lessThan(top('c')));
await tester.tap(find.byTooltip('Sort'));
await tester.pumpAndSettle();
await choose(tester, 'Name (A–Z)');
expect(top('a'), lessThan(top('b')));
expect(top('b'), lessThan(top('c')));
});
testWidgets('renames, and deletes with a moment to undo', (tester) async {
await repository().import(
'japanese',
bytes: pdf('kana'),
fileName: 'kana.pdf',
);
await showApp(tester);
await tester.tap(find.byTooltip('Actions'));
await tester.pumpAndSettle();
await choose(tester, 'Rename');
await tester.enterText(find.byType(TextField), 'Hiragana');
await tester.tap(find.text('Save'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('Hiragana'), findsOneWidget);
await tester.tap(find.byTooltip('Actions'));
await tester.pumpAndSettle();
await choose(tester, 'Delete');
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('Hiragana'), findsNothing);
expect(find.text('PDF deleted'), findsOneWidget);
await tester.tap(find.text('Undo'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('Hiragana'), findsOneWidget);
expect(pdfGateway.deletions, isEmpty);
});
testWidgets('a deadline may be to finish a chapter, read from the PDF', (
tester,
) async {
final pdfs = repository();
final id = (await pdfs.import(
'japanese',
bytes: pdf('pages:40 chapter:1:Kana; chapter:5:Particles;'),
fileName: 'g.pdf',
) as PdfImported).id;
await pdfs.setPageCount(id, 40);
await showApp(tester);
await tester.tap(find.byTooltip('Actions'));
await tester.pumpAndSettle();
await choose(tester, 'Set reading deadline');
await tester.tap(find.text('Or finish a chapter'));
await tester.pumpAndSettle();
await tester.tap(find.text('Kana · to p.4').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Save deadline'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect((await pdfs.byId(id))!.content.deadline!.targetPage, 4);
});
testWidgets('a deadline set from the row shows its pace', (tester) async {
final pdfs = repository();
final id = (await pdfs.import(
'japanese',
bytes: pdf('k'),
fileName: 'k.pdf',
) as PdfImported).id;
await pdfs.setPageCount(id, 40);
await showApp(tester);
await tester.tap(find.byTooltip('Actions'));
await tester.pumpAndSettle();
await choose(tester, 'Set reading deadline');
expect(find.text('Reach page (of 40)'), findsOneWidget);
await tester.tap(find.text('Save deadline'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
final deadline = (await pdfs.byId(id))!.content.deadline!;
expect(deadline.targetPage, 40);
expect(find.textContaining('/day to reach page 40'), findsOneWidget);
});
}

View file

@ -0,0 +1,369 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/pdf_capture.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:material_ui/material_ui.dart' hide Card;
import '../../../support/fake_asset_gateway.dart';
import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_note_gateway.dart';
import '../../../support/fake_pdf_engine.dart';
import '../../../support/fake_pdf_source_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/fake_tag_gateway.dart';
import '../../../support/memory_asset_file_cache.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
void main() {
late FakeStudysetGateway studysets;
late FakePdfSourceGateway pdfGateway;
late FakeAssetGateway assetGateway;
late MemoryAssetFileCache files;
late FakeCardGateway cardGateway;
late FakeNoteGateway noteGateway;
late FakeTagGateway tagGateway;
late FakePdfEngine engine;
setUp(() {
studysets = FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
);
pdfGateway = FakePdfSourceGateway();
assetGateway = FakeAssetGateway();
files = MemoryAssetFileCache();
cardGateway = FakeCardGateway();
noteGateway = FakeNoteGateway();
tagGateway = FakeTagGateway();
engine = FakePdfEngine();
});
/// A PDF of 40 pages in two chapters, stored before the app starts.
Future<String> stored({int? readTo}) async {
final assets = AssetRepository(
assetGateway,
FakeAssetServerGateway(),
files,
);
final pdfs = PdfSourceRepository(pdfGateway, assets);
addTearDown(() async {
await pdfs.dispose();
await assets.dispose();
});
final id = (await pdfs.import(
'japanese',
bytes: Uint8List.fromList(
utf8.encode(
'%PDF-1.7 pages:40 chapter:1:Kana; chapter:5:Particles;',
),
),
fileName: 'Grammar.pdf',
) as PdfImported).id;
if (readTo != null) {
await pdfs.reached(id, readTo);
}
return id;
}
Future<void> read(
WidgetTester tester,
String id, {
Size size = const Size(1400, 1000),
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
pdfGateway: pdfGateway,
assetGateway: assetGateway,
assetFiles: files,
cardGateway: cardGateway,
noteGateway: noteGateway,
tagGateway: tagGateway,
pdfEngine: engine,
),
location: RoutePaths.pdf(id),
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
Future<Map<String, int>> furthest() {
final pdfs = PdfSourceRepository(
pdfGateway,
AssetRepository(assetGateway, FakeAssetServerGateway(), files),
);
addTearDown(pdfs.dispose);
return pdfs.furthestPages();
}
testWidgets('opens on its first page with its chapter; the counter turns '
'the pages, and reading is kept', (tester) async {
final id = await stored();
await read(tester, id);
expect(find.text('Page 1 of 40'), findsOneWidget);
expect(find.text('Kana'), findsOneWidget);
expect(find.text('/ 40'), findsOneWidget);
await tester.enterText(find.widgetWithText(TextField, '1'), '6');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(find.text('Page 6 of 40'), findsOneWidget);
expect(find.text('Particles'), findsOneWidget);
await tester.tap(find.byTooltip('Next page'));
await tester.pumpAndSettle();
expect(find.text('Page 7 of 40'), findsOneWidget);
expect(await furthest(), {id: 7});
});
testWidgets('opens where reading left off', (tester) async {
final id = await stored(readTo: 12);
await read(tester, id);
expect(find.text('Page 12 of 40'), findsOneWidget);
});
testWidgets('searches the text, from match to match, and zooms', (
tester,
) async {
final id = await stored();
await read(tester, id);
await tester.tap(find.byTooltip('Search PDF'));
await tester.pumpAndSettle();
await tester.enterText(find.widgetWithText(TextField, 'Find in PDF'), 'は');
await tester.pump(const Duration(seconds: 1));
expect(engine.viewers.last.searched, ['は']);
expect(find.text('1 of 3'), findsOneWidget);
await tester.tap(find.byTooltip('Next match'));
await tester.pump();
expect(find.text('2 of 3'), findsOneWidget);
await tester.tap(find.byTooltip('Zoom in'));
await tester.pump();
expect(find.text('125%'), findsOneWidget);
});
testWidgets('selected text goes to a new card, which is tied to its page '
'and tagged with its chapter', (tester) async {
final id = await stored();
await read(tester, id);
await tester.enterText(find.widgetWithText(TextField, '1'), '5');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
engine.select('は marks\nthe topic');
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, 'Question'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(
find.textContaining('は marks the topic', findRichText: true),
findsOneWidget,
);
await tester.tap(find.widgetWithText(FilledButton, 'Create'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('Flashcard created'), findsOneWidget);
final cards = CardRepository(cardGateway);
final tags = TagRepository(tagGateway);
addTearDown(cards.dispose);
addTearDown(tags.dispose);
final card = (await cards.cardsOf('japanese')).single;
expect(card.content.source, (pdfId: id, page: 5));
expect(card.content.front.plainText, 'は marks the topic');
expect((await tags.read()).of(card.id).single.name, 'particles');
expect(find.text('Page 5 · 1'), findsOneWidget);
});
testWidgets("the page's note is written in the Notes tab and kept for "
'that page', (tester) async {
final id = await stored();
await read(tester, id);
await tester.tap(find.text('Notes'));
await tester.pumpAndSettle();
final editorState = tester
.widget<AppFlowyEditor>(
find.descendant(
of: find.byType(RichContentEditor),
matching: find.byType(AppFlowyEditor),
),
)
.editorState;
final transaction = editorState.transaction
..insertText(editorState.getNodeAtPath([0])!, 0, 'ね is a softener');
await editorState.apply(transaction);
await tester.pump(const Duration(seconds: 2));
await tester.pumpAndSettle();
expect(find.text('Saved'), findsOneWidget);
final notes = NoteRepository(noteGateway);
addTearDown(notes.dispose);
final note = (await notes.notesOf('japanese')).single;
expect(note.content.source, (pdfId: id, page: 1));
expect(note.content.body.plainText, 'ね is a softener');
});
testWidgets('a screenshot of a region of the page goes into the card as '
'an image that knows its page', (tester) async {
final id = await stored();
await read(tester, id);
await tester.tap(find.text('Create flashcard'));
await tester.pumpAndSettle();
final question = find.descendant(
of: find.byType(RichContentEditor).first,
matching: find.byType(AppFlowyEditor),
);
await tester.tap(question);
await tester.pump(const Duration(seconds: 1));
await tester.tap(find.byTooltip('Insert').first);
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Screenshot of the page'));
await tester.pumpAndSettle();
await tester.tap(find.text('Screenshot of the page'));
await tester.pumpAndSettle();
expect(find.text('Cancel screenshot'), findsOneWidget);
final overlay = tester.getTopLeft(find.byType(PdfCaptureOverlay));
await tester.dragFrom(
overlay + const Offset(70, 100),
const Offset(140, 200),
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(engine.rendered.single.page, 1);
expect(
engine.rendered.single.region.left,
closeTo(70 / 700, 0.01),
);
final editorState = tester.widget<AppFlowyEditor>(question).editorState;
final image = editorState.document.root.children.first;
expect(image.type, 'image');
expect(image.attributes[imagePdfKey], id);
expect(image.attributes[imagePageKey], 1);
expect(find.text('Screenshot saved'), findsOneWidget);
});
testWidgets('on a phone, the pages fill the screen, the panels open from '
'the bottom', (tester) async {
final id = await stored();
await read(tester, id, size: const Size(412, 915));
expect(find.text('Page 1 of 40'), findsOneWidget);
expect(find.byTooltip('Zoom in'), findsNothing);
await tester.tap(find.byTooltip('Expand'));
await tester.pumpAndSettle();
expect(find.text('Create flashcard'), findsOneWidget);
expect(find.byTooltip('Collapse'), findsOneWidget);
});
testWidgets('a card made from a PDF shows its page while studied, and '
'opens the PDF there', (tester) async {
final id = await stored();
final cards = CardRepository(cardGateway);
addTearDown(cards.dispose);
await cards.create(
'japanese',
CardContent(
front: RichContent.plain('は'),
back: RichContent.plain('topic'),
source: (pdfId: id, page: 5),
),
);
tester.view.physicalSize = const Size(1400, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
pdfGateway: pdfGateway,
assetGateway: assetGateway,
assetFiles: files,
cardGateway: cardGateway,
pdfEngine: engine,
),
location: RoutePaths.study('japanese'),
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
await tester.tap(find.text('Grammar · p. 5'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('Page 5 of 40'), findsOneWidget);
});
testWidgets('reading on past a chapter with cards of its own suggests '
'studying them, that chapter alone', (tester) async {
final id = await stored();
final cards = CardRepository(cardGateway);
final tags = TagRepository(tagGateway);
addTearDown(cards.dispose);
addTearDown(tags.dispose);
final card = await cards.create(
'japanese',
CardContent(
front: RichContent.plain('か'),
back: RichContent.plain('ka'),
source: (pdfId: id, page: 2),
),
);
await tags.setTagsOf(card, {await tags.create('Kana')});
await read(tester, id);
await tester.enterText(find.widgetWithText(TextField, '1'), '4');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(find.textContaining('Finished'), findsNothing);
await tester.tap(find.byTooltip('Next page'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(
find.text('Finished “Kana” — study the card you made?'),
findsOneWidget,
);
await tester.tap(find.text('Study now'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.textContaining('か', findRichText: true), findsOneWidget);
});
testWidgets("a PDF whose file isn't on this device says so", (tester) async {
final id = await stored();
files.files.clear();
await read(tester, id);
expect(find.textContaining("isn't on this device yet"), findsOneWidget);
expect(find.text('Try again'), findsOneWidget);
});
}

View file

@ -0,0 +1,124 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:material_ui/material_ui.dart';
import '../../../support/fake_asset_gateway.dart';
import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/fake_pdf_source_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/memory_asset_file_cache.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
void main() {
late FakePdfSourceGateway pdfGateway;
late FakeAssetGateway assetGateway;
setUp(() {
pdfGateway = FakePdfSourceGateway();
assetGateway = FakeAssetGateway();
});
Future<PdfSourceRepository> repository() async {
final assets = AssetRepository(
assetGateway,
FakeAssetServerGateway(),
MemoryAssetFileCache(),
);
final pdfs = PdfSourceRepository(pdfGateway, assets);
addTearDown(() async {
await pdfs.dispose();
await assets.dispose();
});
return pdfs;
}
Future<String> withDeadline(
PdfSourceRepository pdfs,
String title,
ReadingDeadline deadline,
) async {
final id = (await pdfs.import(
'japanese',
bytes: Uint8List.fromList(utf8.encode('%PDF-1.7 $title')),
fileName: '$title.pdf',
) as PdfImported).id;
await pdfs.setDeadline(id, deadline);
return id;
}
Future<void> showHome(WidgetTester tester, {String? location}) async {
tester.view.physicalSize = const Size(1200, 1400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
studysetGateway: FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
),
pdfGateway: pdfGateway,
assetGateway: assetGateway,
),
location: location ?? RoutePaths.home,
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
testWidgets("Home lists the deadlines running, with today's page, and "
'those to come; a tap shows them on the timeline', (tester) async {
await withClock(Clock.fixed(DateTime(2026, 10, 3, 9)), () async {
final pdfs = await repository();
final grammar = await withDeadline(
pdfs,
'Grammar',
const ReadingDeadline(
targetPage: 100,
due: (year: 2026, month: 10, day: 12),
),
);
await pdfs.reached(grammar, 40);
await withDeadline(
pdfs,
'Kanji',
const ReadingDeadline(
targetPage: 50,
due: (year: 2026, month: 11, day: 30),
start: (year: 2026, month: 10, day: 10),
),
);
await showHome(tester);
expect(find.text('Reading deadlines'), findsOneWidget);
expect(find.text('p.40/100 · 10 days left'), findsOneWidget);
expect(find.text('to p.46 today'), findsOneWidget);
expect(find.text('Starts in 7 days'), findsOneWidget);
await tester.tap(find.text('Reading deadlines'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('Reading timeline'), findsOneWidget);
expect(find.text('p.40/100'), findsOneWidget);
expect(find.text('Kanji'), findsOneWidget);
});
});
testWidgets('Home shows no deadlines card without any', (tester) async {
await showHome(tester);
expect(find.text('Reading deadlines'), findsNothing);
});
}

View file

@ -103,7 +103,12 @@ void main() {
goal: const StudyGoal(period: GoalPeriod.week, target: 150),
restDays: {DateTime(2026, 10, 5), DateTime(2026, 12, 24)},
home: const HomeLayout(
order: [HomeWidget.activity, HomeWidget.goal, HomeWidget.week],
order: [
HomeWidget.activity,
HomeWidget.goal,
HomeWidget.deadlines,
HomeWidget.week,
],
hidden: {HomeWidget.week},
),
);
@ -126,6 +131,7 @@ void main() {
expect(settings.home.order, [
HomeWidget.activity,
HomeWidget.goal,
HomeWidget.deadlines,
HomeWidget.week,
]);
expect(settings.home.hidden, isEmpty);

View file

@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
@ -6,6 +7,7 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_pa
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
@ -16,11 +18,15 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_reposit
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
import '../../../support/fake_asset_gateway.dart';
import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_learning_path_gateway.dart';
import '../../../support/fake_note_gateway.dart';
import '../../../support/fake_pdf_source_gateway.dart';
import '../../../support/fake_review_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/memory_asset_file_cache.dart';
void main() {
late FakeStudysetGateway gateway;
@ -61,6 +67,14 @@ void main() {
cards,
notes,
paths,
PdfSourceRepository(
FakePdfSourceGateway(),
AssetRepository(
FakeAssetGateway(),
FakeAssetServerGateway(),
MemoryAssetFileCache(),
),
),
undoWindow: undoWindow,
);
bloc = StudysetListBloc(

View file

@ -1,4 +1,8 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
@ -6,15 +10,20 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_pa
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import '../../../support/eventually.dart';
import '../../../support/fake_asset_gateway.dart';
import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_learning_path_gateway.dart';
import '../../../support/fake_note_gateway.dart';
import '../../../support/fake_pdf_source_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/memory_asset_file_cache.dart';
void main() {
late FakeStudysetGateway studysetGateway;
@ -25,6 +34,9 @@ void main() {
late NoteRepository notes;
late FakeLearningPathGateway pathGateway;
late LearningPathRepository paths;
late FakePdfSourceGateway pdfGateway;
late AssetRepository assets;
late PdfSourceRepository pdfs;
late StudysetDeletions deletions;
setUp(() {
@ -36,6 +48,13 @@ void main() {
notes = NoteRepository(noteGateway);
pathGateway = FakeLearningPathGateway();
paths = LearningPathRepository(pathGateway);
pdfGateway = FakePdfSourceGateway();
assets = AssetRepository(
FakeAssetGateway(),
FakeAssetServerGateway(),
MemoryAssetFileCache(),
);
pdfs = PdfSourceRepository(pdfGateway, assets);
});
tearDown(() async {
await deletions.dispose();
@ -43,6 +62,8 @@ void main() {
await cards.dispose();
await notes.dispose();
await paths.dispose();
await pdfs.dispose();
await assets.dispose();
});
void open(Duration undoWindow) => deletions = StudysetDeletions(
@ -50,6 +71,7 @@ void main() {
cards,
notes,
paths,
pdfs,
undoWindow: undoWindow,
);
@ -59,8 +81,8 @@ void main() {
);
test('a deletion waits for the time to undo, then deletes the whole '
'subtree, what is inside first, each with its cards, notes and paths '
'blanked', () async {
'subtree, what is inside first, each with its cards, notes, paths and '
'PDFs blanked', () async {
open(const Duration(milliseconds: 50));
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
final kana = await studysets.create(
@ -76,6 +98,11 @@ void main() {
kana,
LearningPathContent.titled('Hiragana'),
);
final kanaPdf = (await pdfs.import(
kana,
bytes: Uint8List.fromList(utf8.encode('%PDF-1.7 kana')),
fileName: 'kana.pdf',
) as PdfImported).id;
deletions.delete(japanese);
expect(deletions.pending, {japanese});
@ -92,6 +119,7 @@ void main() {
'blank path $kanaPath',
'delete path $kanaPath',
]);
expect(pdfGateway.deletions, ['blank $kanaPdf', 'delete $kanaPdf']);
expect(await studysets.all(), isEmpty);
});