feat: a round in the order cards come, shared daily limits, and a start screen

Studying:
- A round is one list in the order its cards come: those done, the one
  shown, those to come. A card due again within the day goes back in
  after 4 more cards when rated Bad, at the end otherwise, so the pills
  above the card follow it; the buttons say "in 4 cards", "at the end",
  "now" (round_order.dart).
- The studied studyset's daily limits hold for everything inside it
  together, as Anki's do for subdecks: twenty topics start 20 new cards
  a day in all, not 20 each.
- The study header's studyset name shows whole on hover or long press.
- The assistant's button sits at the lower left: in the Library across
  from Create studyset, in a studyset above its bar, and higher while
  studying, above the buttons that answer the card.

A studyset:
- Progress also counts the learning paths' steps done and lists the
  PDFs not read to their end, begun first, each opening the reader,
  across its topics (MaterialProgressCubit).
- Next up starts with studying for the exam while one is ahead: its
  reviews due and the new cards its pace leaves for the day.
- Learning paths tagged with a level count towards its automatic exam
  plan and offer it, as cards do.
- A step's first heading equal to its title is dropped on import and
  hidden in paths imported before.

Signing out, or a session the server ended, leads to a start screen
(GitHub, email, without an account, another instance) rather than a new
anonymous account; only the very first start makes one by itself.
Settings names the kind of account and its instance.

Also: the Watch screen reads the cards once for held words and their
progress; its ±10 s buttons tick as the navigation bar does; a map card
keeps its view as its caption changes and brings a picked region
towards the middle; an empty or failed library can be pulled to sync.

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-17 00:39:34 +02:00
commit de6af3efab
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
20 changed files with 2677 additions and 293 deletions

View file

@ -24,6 +24,41 @@ void main() {
};
group('RichContent', () {
test('without a first heading that repeats a title, whatever its case '
'and spaces; anything else stays', () {
RichContent page(List<Map<String, Object?>> children) => RichContent({
'document': {'type': 'page', 'children': children},
});
Map<String, Object?> heading(String text) => {
'type': 'heading',
'data': {
'level': 1,
'delta': [
{'insert': text},
],
},
};
final text = {
'type': 'paragraph',
'data': {
'delta': [
{'insert': 'In Japanese…'},
],
},
};
expect(
page([heading('What is Kanji?'), text])
.withoutHeading('what is kanji?'),
page([text]),
);
final other = page([heading('Kanji'), text]);
expect(other.withoutHeading('What is Kanji?'), other);
final later = page([text, heading('What is Kanji?')]);
expect(later.withoutHeading('What is Kanji?'), later);
expect(other.withoutHeading(''), other);
});
test('equals content with the same JSON, whatever the key order', () {
final a = RichContent({
'document': {'type': 'page', 'children': <Object?>[]},

View file

@ -477,13 +477,15 @@ void main() {
expect(find.text("Lumo couldn't be opened."), findsOneWidget);
});
testWidgets("the Library's button sits above Create studyset, unfolds into "
'the field, and folds back once sent', (tester) async {
testWidgets("the Library's button sits at the lower left, Create studyset "
'at the lower right, unfolds into the field, and folds back once '
'sent', (tester) async {
await showApp(tester, location: RoutePaths.library);
final button = tester.getCenter(find.byTooltip('Ask or search'));
final create = tester.getCenter(find.byTooltip('Create studyset'));
expect(button.dy, lessThan(create.dy));
expect(button.dx, closeTo(create.dx, 8));
expect(button.dx, lessThan(tester.view.physicalSize.width / 4));
expect(create.dx, greaterThan(tester.view.physicalSize.width * 3 / 4));
expect(button.dy, closeTo(create.dy, 8));
await unfold(tester);
expect(find.byTooltip('Create studyset'), findsNothing);
@ -497,8 +499,9 @@ void main() {
testWidgets("a studyset's name goes along only when chosen, and never "
'into a web search', (tester) async {
await showApp(tester, location: RoutePaths.studyset('japanese'));
// At the lower left, above the studyset's bar.
final center = tester.getCenter(find.byTooltip('Ask or search'));
expect(center.dx, greaterThan(300));
expect(center.dx, lessThan(100));
expect(
center.dy,
lessThan(tester.getCenter(find.byTooltip('Add material')).dy),
@ -518,6 +521,30 @@ void main() {
expect(find.text('Studyset: Japanese'), findsNothing);
});
testWidgets('in a session, the button stands at the lower left, above '
'Check, covering nothing that answers the card', (tester) async {
final repository = CardRepository(cards);
addTearDown(repository.dispose);
await repository.create(
'japanese',
CardContent(
type: CardType.multipleChoice,
front: paragraph('Capital of Japan?'),
back: RichContent.empty(),
answers: [
CardAnswer(content: paragraph('Tokyo'), correct: true),
CardAnswer(content: paragraph('Kyoto')),
],
),
);
await showApp(tester, location: RoutePaths.study('japanese'));
final button = tester.getRect(find.byTooltip('Ask or search'));
final check = tester.getRect(find.widgetWithText(FilledButton, 'Check'));
expect(button.center.dx, lessThan(tester.view.physicalSize.width / 4));
expect(button.bottom, lessThan(check.top));
});
testWidgets("in a session, the card's parts go along only as chosen, and "
'a chosen hint alone is enough to search', (tester) async {
// A desktop: a web search opens Brave Search's results.

View file

@ -5,23 +5,27 @@ import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
import '../../../support/fake_auth_gateway.dart';
import '../../../support/memory_sign_in_addresses.dart';
import '../../../support/memory_signed_out_mark.dart';
void main() {
late FakeAuthGateway gateway;
late MemorySignedOutMark mark;
late SessionBloc bloc;
SessionStateActive anonymous(int number) => SessionStateActive(
SignedIn(authUserId: 'anonymous-$number', isIdentified: false),
);
void start({AuthStatus status = const SignedOut()}) {
void start({AuthStatus status = const SignedOut(), bool signedOut = false}) {
gateway = FakeAuthGateway(status: status);
bloc = SessionBloc(AuthRepository(gateway, MemorySignInAddresses()));
mark = MemorySignedOutMark(signedOut);
bloc = SessionBloc(AuthRepository(gateway, MemorySignInAddresses()), mark);
}
tearDown(() => bloc.close());
test('signs in anonymously when nobody is signed in', () async {
test('signs in anonymously at a first start, nobody having signed out '
'here', () async {
start();
bloc.add(const SessionEventStarted());
@ -61,27 +65,101 @@ void main() {
);
});
test(
'starts anew with another anonymous account after signing out',
() async {
const stored = SignedIn(authUserId: 'stored', isIdentified: false);
start(status: stored);
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateActive(stored)));
test('after signing out, asks how to start, making no account; the next '
'start of the app asks again', () async {
const stored = SignedIn(authUserId: 'stored', isIdentified: false);
start(status: stored);
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateActive(stored)));
bloc.add(const SessionEventSignOutRequested());
bloc.add(const SessionEventSignOutRequested());
await expectLater(
bloc.stream,
emitsInOrder([const SessionStateLoading(), anonymous(1)]),
);
// Signing out of an anonymous account deletes it.
expect(gateway.accountDeletions, 1);
},
);
await expectLater(
bloc.stream,
emitsInOrder([
const SessionStateLoading(),
const SessionStateSignedOut(),
]),
);
// Signing out of an anonymous account deletes it.
expect(gateway.accountDeletions, 1);
expect(gateway.signIns, 0);
expect(mark.signedOut, isTrue);
test('deleting the account starts anew with another anonymous '
'account', () async {
await bloc.close();
bloc = SessionBloc(AuthRepository(gateway, MemorySignInAddresses()), mark)
..add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
expect(gateway.signIns, 0);
});
test('starting without an account from there signs in anonymously, and '
'the device is no longer signed out', () async {
start(signedOut: true);
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
bloc.add(const SessionEventStartedAnonymously());
await expectLater(
bloc.stream,
emitsInOrder([const SessionStateLoading(), anonymous(1)]),
);
expect(mark.signedOut, isFalse);
});
test('signing in with GitHub from there switches to its person; '
'cancelled, it asks again', () async {
start(signedOut: true);
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
bloc.add(const SessionEventGitHubRequested());
await expectLater(
bloc.stream,
emitsThrough(
const SessionStateActive(
SignedIn(authUserId: 'github-person', isIdentified: true),
),
),
);
expect(gateway.signIns, 0);
expect(mark.signedOut, isFalse);
});
test('with an email address from there, it starts without an account and '
'asks for the address', () async {
start(signedOut: true);
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
bloc.add(const SessionEventEmailRequested());
await expectLater(
bloc.stream,
emitsThrough(
const SessionStateActive(
SignedIn(authUserId: 'anonymous-1', isIdentified: false),
signsInWithEmail: true,
),
),
);
});
test('a session the server ended asks how to start too', () async {
const stored = SignedIn(authUserId: 'github-1', isIdentified: true);
start(status: stored);
bloc.add(const SessionEventStarted());
await expectLater(bloc.stream, emits(const SessionStateActive(stored)));
gateway.revokeSession();
await expectLater(bloc.stream, emits(const SessionStateSignedOut()));
expect(gateway.signIns, 0);
});
test('deleting the account asks how to start', () async {
const stored = SignedIn(authUserId: 'github-1', isIdentified: true);
start(status: stored);
bloc.add(const SessionEventStarted());
@ -91,8 +169,12 @@ void main() {
await expectLater(
bloc.stream,
emitsInOrder([const SessionStateLoading(), anonymous(1)]),
emitsInOrder([
const SessionStateLoading(),
const SessionStateSignedOut(),
]),
);
expect(gateway.accountDeletions, 1);
expect(gateway.signIns, 0);
});
}

View file

@ -66,6 +66,25 @@ void main() {
);
});
testWidgets('Settings says the account is anonymous, on which instance; '
'one with GitHub, signed in', (tester) async {
await openSettings(tester);
expect(find.text('Anonymous on learn.example.org'), findsOneWidget);
await tester.tap(find.byTooltip('Back'));
await tester.pumpAndSettle();
await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Settings'));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.text('Delete account'),
200,
scrollable: find.byType(Scrollable).first,
);
expect(find.text('Signed in on learn.example.org'), findsOneWidget);
});
testWidgets(
'signing out of an anonymous account asks first; Cancel keeps it',
(tester) async {
@ -87,8 +106,8 @@ void main() {
},
);
testWidgets('a confirmed sign-out starts anew at home with another '
'account', (tester) async {
testWidgets('a confirmed sign-out asks how to start, making no account; '
'starting without one starts anew at home', (tester) async {
await openSettings(tester);
await tester.tap(find.text('Sign out'));
await tester.pumpAndSettle();
@ -98,14 +117,61 @@ void main() {
// Signing out of an anonymous account deletes it.
expect(gateway.accountDeletions, 1);
expect(gateway.status, const SignedOut());
expect(find.text('Signed out'), findsOneWidget);
expect(find.text('Start on learn.example.org:'), findsOneWidget);
expect(find.text('Sign in with GitHub'), findsOneWidget);
expect(find.text('Continue with email'), findsOneWidget);
await tester.tap(find.text('Start without an account'));
await tester.pumpAndSettle();
expect(
gateway.status,
const SignedIn(authUserId: 'anonymous-2', isIdentified: false),
);
expect(find.text('Settings'), findsNothing);
expect(home, findsOneWidget);
});
testWidgets('from there, GitHub signs in its person, making no account '
'here', (tester) async {
await openSettings(tester);
await tester.tap(find.text('Sign out'));
await tester.pumpAndSettle();
await tester.tap(find.text('Sign out').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle();
expect(
gateway.status,
const SignedIn(authUserId: 'github-person', isIdentified: true),
);
expect(home, findsOneWidget);
expect(gateway.signIns, 1);
});
testWidgets('from there, an email address starts without an account and '
'asks for the address on Profile', (tester) async {
await openSettings(tester);
await tester.tap(find.text('Sign out'));
await tester.pumpAndSettle();
await tester.tap(find.text('Sign out').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Continue with email'));
await tester.pumpAndSettle();
expect(
gateway.status,
const SignedIn(authUserId: 'anonymous-2', isIdentified: false),
);
// The email sign-in, over the Profile tab.
expect(find.text('Continue with email'), findsWidgets);
expect(find.byType(TextField), findsOneWidget);
});
testWidgets('deleting the account asks first; Cancel keeps it', (
tester,
) async {
@ -121,8 +187,8 @@ void main() {
expect(find.text('Settings'), findsOneWidget);
});
testWidgets('a confirmed deletion starts anew at home with another '
'account', (tester) async {
testWidgets('a confirmed deletion asks how to start; starting without an '
'account starts anew at home', (tester) async {
await openSettings(tester);
await tester.tap(find.text('Delete account'));
await tester.pumpAndSettle();
@ -131,11 +197,13 @@ void main() {
await tester.pumpAndSettle();
expect(gateway.accountDeletions, 1);
expect(find.text('Signed out'), findsOneWidget);
await tester.tap(find.text('Start without an account'));
await tester.pumpAndSettle();
expect(
gateway.status,
const SignedIn(authUserId: 'anonymous-2', isIdentified: false),
);
expect(find.text('Settings'), findsNothing);
expect(home, findsOneWidget);
});
}

View file

@ -534,19 +534,22 @@ void main() {
);
test("a word's progress comes from its card anywhere in the studyset "
'and its topics', () async {
'and its topics, the cards read once for which words it holds and '
'how far each is learnt', () async {
await adder.add('n3', [taberu]);
await adder.add('other', [terebi]);
final reviews = ReviewRepository(FakeReviewGateway());
final progress = await EntryProgress(
cards,
reviews,
studysets,
).of('vocabulary');
final held = await adder.heldIn('vocabulary');
final [eat] = await cards.cardsOf('n3');
expect(held, {taberu.ref: eat.id});
final progress = await EntryProgress(reviews).of(held);
expect(progress.keys, [taberu.ref]);
expect(progress[taberu.ref]?.isNew, isTrue);
// A word added since is held with its card, without reading again.
await adder.add('vocabulary', [omae]);
expect((await adder.heldIn('vocabulary')).keys, contains(omae.ref));
});
test('makes each word a card once, tagged with its level and word '

View file

@ -197,6 +197,94 @@ void main() {
expect(picked, ['JP']);
});
testWidgets('a region picked comes to the middle of the map, at the zoom '
'it had, as far as the layer still fills it: shown whole, it stays '
'put', (tester) async {
final picked = await show(tester, around: {'JP-13', 'JP-27'});
await choose(tester, 'Kantō');
final canvas = tester.getRect(
find.descendant(
of: find.byType(RegionMap),
matching: find.byType(ClipRRect),
),
);
final chiba = atlas.region('JP-12')!.marker;
final whole = state(tester).globalOffsetOf(chiba);
await tapAt(tester, chiba);
expect(picked, ['JP-12']);
expect(state(tester).globalOffsetOf(chiba), whole);
// Zoomed in around Chiba, it stays where it was, off the middle, and
// comes to it.
final mouse = TestPointer(1, PointerDeviceKind.mouse);
await tester.sendEventToBinding(
mouse.hover(state(tester).globalOffsetOf(chiba)),
);
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendEventToBinding(mouse.scroll(const Offset(0, -200)));
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pumpAndSettle();
final zoom = apart(tester, tokyo, chiba);
expect(
(state(tester).globalOffsetOf(chiba) - canvas.center).distance,
greaterThan(20),
);
await tapAt(tester, chiba);
expect(picked, ['JP-12', 'JP-12']);
expect(
(state(tester).globalOffsetOf(chiba) - canvas.center).distance,
lessThan(2),
);
expect(apart(tester, tokyo, chiba), closeTo(zoom, 0.5));
});
testWidgets('a map that grows or shrinks keeps where it shows and its zoom, '
'never shown whole again', (tester) async {
tester.view
..physicalSize = const Size(400, 640)
..devicePixelRatio = 1;
addTearDown(tester.view.reset);
var height = 500.0;
late StateSetter resize;
await tester.pumpWidget(
MaterialApp(
theme: appTheme(Brightness.light),
localizationsDelegates: appLocalizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) {
resize = setState;
return SizedBox(
height: height,
child: RegionMap(atlas: atlas, around: const {'JP-13'}),
);
},
),
),
),
);
await tester.pumpAndSettle();
final mouse = TestPointer(1, PointerDeviceKind.mouse);
await tester.sendEventToBinding(
mouse.hover(state(tester).globalOffsetOf(tokyo)),
);
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendEventToBinding(mouse.scroll(const Offset(0, -200)));
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pumpAndSettle();
const kyoto = (135.77, 35.01);
final zoomed = apart(tester, tokyo, kyoto);
// As a caption below it grows by a line.
resize(() => height = 470);
await tester.pumpAndSettle();
expect(apart(tester, tokyo, kyoto), closeTo(zoomed, 0.5));
});
testWidgets('a group of one region picks it at once', (tester) async {
final picked = await show(tester, around: {'JP-13', 'JP-27'});

View file

@ -506,7 +506,18 @@ void main() {
});
testWidgets('the player has play or pause with 10 seconds back and on '
'beside it, and where it is from 0:00 to its length', (tester) async {
'beside it, each a short tick where the device vibrates, and where it '
'is from 0:00 to its length', (tester) async {
final ticks = <Object?>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'HapticFeedback.vibrate') {
ticks.add(call.arguments);
}
return null;
},
);
await watch(tester);
await playTo(tester, const Duration(minutes: 1));
expect(find.text('1:00'), findsOneWidget);
@ -519,6 +530,7 @@ void main() {
await tester.tap(find.byTooltip('10 seconds back'));
await tester.pumpAndSettle();
expect(players.last!.state.value.position, const Duration(seconds: 50));
expect(ticks, List.filled(3, 'HapticFeedbackType.selectionClick'));
});
testWidgets("YouTube's keys work, the lines' too, and ? lists them", (

View file

@ -55,6 +55,23 @@ Map<String, Object?> paragraph(List<Map<String, Object?>> delta) => {
};
void main() {
test("a step's first heading repeating its title is left out of its "
'text, as the title shows above it', () {
final parsed = parsePathImport('''
{ "path": { "title": "Kanji", "steps": [
{ "title": "What is Kanji?", "type": "info", "content": [
{ "type": "heading", "level": 1, "text": "What is Kanji?" },
{ "type": "paragraph", "text": "In Japanese…" } ] },
{ "title": "Readings", "type": "info", "content": [
{ "type": "heading", "level": 1, "text": "On and kun" },
{ "type": "paragraph", "text": "Two kinds." } ] }
] } }''');
final [first, second] = parsed.steps;
expect(first.content.body.plainText, 'In Japanese…');
expect(second.content.body.plainText, 'On and kun\nTwo kinds.');
});
test('reads a path of every kind of step', () {
final parsed = parsePathImport(_lesson);

View file

@ -754,6 +754,58 @@ void main() {
]);
});
testWidgets('a step whose text opens with its own title as a heading '
'shows the title once', (tester) async {
final paths = repository();
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Kanji'),
);
await paths.createStep(
id,
StepKind.info,
StepContent.empty(StepKind.info)
.withField(StepField.title, 'What is Kanji?')
.withField(
StepField.body,
RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'heading',
'data': {
'level': 1,
'delta': [
{'insert': 'What is Kanji?'},
],
},
},
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': 'In Japanese…'},
],
},
},
],
},
}),
),
);
await showApp(tester, RoutePaths.path('japanese', id));
expect(
find.textContaining('What is Kanji?', findRichText: true),
findsOneWidget,
);
expect(
find.textContaining('In Japanese…', findRichText: true),
findsOneWidget,
);
});
testWidgets('fills the blanks by tapping words', (tester) async {
final paths = repository();
final id = await paths.createPath(

View file

@ -6,11 +6,13 @@ import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.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/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_session_bloc.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/review.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/round_order.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_focus.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
@ -21,6 +23,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_conten
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_learning_path_gateway.dart';
import '../../../support/fake_review_gateway.dart';
import '../../../support/fake_settings_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
@ -41,6 +44,7 @@ void main() {
late FakeReviewGateway reviewGateway;
late ReviewRepository reviews;
late TagRepository tags;
late LearningPathRepository paths;
late SettingsRepository settings;
late DateTime now;
@ -56,6 +60,7 @@ void main() {
reviewGateway = FakeReviewGateway();
reviews = ReviewRepository(reviewGateway);
tags = TagRepository(FakeTagGateway());
paths = LearningPathRepository(FakeLearningPathGateway());
settings = SettingsRepository(FakeSettingsGateway());
now = DateTime.utc(2026, 10, 5, 9);
});
@ -64,6 +69,7 @@ void main() {
await studysets.dispose();
await cards.dispose();
await tags.dispose();
await paths.dispose();
await settings.dispose();
});
@ -74,6 +80,7 @@ void main() {
deletions,
reviews,
tags,
paths,
settings,
'japanese',
random: math.Random(1),
@ -557,8 +564,60 @@ void main() {
});
});
test('a card rated Bad comes again 4 cards on, its pill moving with it; '
'one due again within the day goes to the end; one a day or more away '
'is done, its pill among those done', () async {
for (final front in ['a', 'b', 'c', 'd', 'e', 'f']) {
await cards.create('japanese', card(front));
}
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
final a = await showing(bloc, 'a');
expect(a.comesBack[Rating.again], const ComesBackIn(4));
expect(a.comesBack[Rating.good], const ComesBackAtEnd());
// Perfect has a new card due in days: the round is done with it.
expect(a.comesBack.containsKey(Rating.easy), isFalse);
rate(bloc, Rating.again);
final b = await showing(bloc, 'b');
expect(b.pills.map((pill) => pill.state), [
PillState.active,
PillState.pending,
PillState.pending,
PillState.pending,
PillState.again,
PillState.pending,
]);
rate(bloc, Rating.good);
final c = await showing(bloc, 'c');
expect(c.pills.map((pill) => pill.state), [
PillState.active,
PillState.pending,
PillState.pending,
PillState.again,
PillState.pending,
PillState.again,
]);
rate(bloc, Rating.easy);
final d = await showing(bloc, 'd');
expect(d.pills.map((pill) => pill.state), [
PillState.done,
PillState.active,
PillState.pending,
PillState.again,
PillState.pending,
PillState.again,
]);
expect(d.pills.first.rating, Rating.easy);
});
});
test('a card a day or more away is done for the round; one rated Bad '
'comes back in it', () async {
'comes back in it, at once when it is the last', () async {
await cards.create('japanese', card('猫'));
await cards.create('japanese', card('犬'));
@ -568,12 +627,12 @@ void main() {
await showing(bloc, '猫');
rate(bloc, Rating.again);
final dog = await showing(bloc, '犬');
expect(dog.pills.first.state, PillState.again);
expect(dog.pills.last.state, PillState.again);
rate(bloc, Rating.easy);
// Nothing else due: the card in its learning steps comes early.
final again = await showing(bloc, '猫');
expect(again.pills.last.state, PillState.done);
expect(again.pills.first.state, PillState.done);
expect(again.comesBack[Rating.again], const ComesBackNow());
rate(bloc, Rating.easy);
final done = await until(
@ -1112,6 +1171,7 @@ void main() {
deletions,
reviews,
tags,
paths,
settings,
'japanese',
random: math.Random(1),

View file

@ -0,0 +1,25 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/round_order.dart';
void main() {
test('Bad brings a card back 4 cards on: at the end with fewer behind '
'it, at once with none', () {
expect(cardsBefore(Rating.again, 7), 4);
expect(comesBack(Rating.again, 7), const ComesBackIn(4));
expect(cardsBefore(Rating.again, 4), 4);
expect(comesBack(Rating.again, 4), const ComesBackAtEnd());
expect(cardsBefore(Rating.again, 2), 2);
expect(comesBack(Rating.again, 2), const ComesBackAtEnd());
expect(comesBack(Rating.again, 0), const ComesBackNow());
});
test('any other rating that has the card due again within the day brings '
'it back at the end', () {
for (final rating in [Rating.hard, Rating.good, Rating.easy]) {
expect(cardsBefore(rating, 7), 7);
expect(comesBack(rating, 7), const ComesBackAtEnd());
expect(comesBack(rating, 0), const ComesBackNow());
}
});
}

View file

@ -131,20 +131,22 @@ void main() {
expect(queue, ['child-early', 'parent-late', 'parent-new', 'child-new']);
});
test('each studyset starts its own 20 new cards a local day', () {
final parent = [for (var i = 0; i < 25; i++) 'p$i'];
test('the studied studyset starts its 20 new cards a local day with '
'what is inside it, not 20 each, in tree order', () {
final parent = [for (var i = 0; i < 5; i++) 'p$i'];
final child = [for (var i = 0; i < 25; i++) 'c$i'];
final other = [for (var i = 0; i < 25; i++) 'o$i'];
final queue = studyQueue(
groups: [parent, child],
groups: [parent, child, other],
schedules: {
for (final id in [...parent, ...child]) id: fresh,
for (final id in [...parent, ...child, ...other]) id: fresh,
},
now: now,
startOfToday: startOfToday,
);
expect(queue, [...parent.take(20), ...child.take(20)]);
expect(queue, [...parent, ...child.take(15)]);
});
});
@ -169,8 +171,8 @@ void main() {
['c1', 'c2'],
],
options: const [
StudysetStudyOptions(newPerDay: 6),
StudysetStudyOptions(newPerDay: 1),
StudysetStudyOptions(newPerDay: StudysetStudyOptions.unlimited),
],
newPerDay: 1,
schedules: const {},
@ -178,7 +180,43 @@ void main() {
startOfToday: startOfToday,
);
expect(queue, ['a1', 'b1', 'b2', 'b3', 'c1'], reason: 'c by default');
expect(queue, ['a1', 'a2', 'a3', 'b1', 'c1'], reason: 'c by default');
});
test("the studied studyset's limits hold for everything inside it "
'together, counting what was studied today anywhere in it', () {
final queue = studyQueue(
groups: [
['started', 'a1', 'a2', 'reviewed', 'r1'],
['b1', 'b2', 'r2', 'r3', 'learning'],
],
options: const [
StudysetStudyOptions(newPerDay: 2, reviewsPerDay: 3),
StudysetStudyOptions(newPerDay: StudysetStudyOptions.unlimited),
],
schedules: {
'started': CardSchedule(
reviewCount: 1,
due: now.add(const Duration(days: 1)),
firstReviewedAt: startOfToday.add(const Duration(hours: 8)),
lastReviewedAt: startOfToday.add(const Duration(hours: 8)),
),
'reviewed': review(
now.add(const Duration(days: 3)),
lastReviewedAt: startOfToday.add(const Duration(hours: 8)),
),
'r1': review(overdue),
'r2': review(overdue.subtract(const Duration(days: 2))),
'r3': review(overdue.subtract(const Duration(days: 1))),
'learning': dueAt(overdue, learning: true),
},
now: now,
startOfToday: startOfToday,
);
// One new card and two reviews left of the day's; the learning card
// comes anyway.
expect(queue, ['r2', 'r3', 'learning', 'a1']);
});
test('a studyset reviews at most as many cards a day as its options say, '

View file

@ -131,7 +131,9 @@ void main() {
]) {
expect(find.byIcon(face), findsOneWidget);
}
expect(find.text('in 1 min'), findsOneWidget);
// The only card of the round: Bad, OK and Good bring it again at
// once; Perfect has it due in days.
expect(find.text('now'), findsNWidgets(3));
await tester.tap(find.text('Show answer'));
await tester.pumpAndSettle();
@ -139,6 +141,21 @@ void main() {
expect(find.textContaining('cat', findRichText: true), findsOneWidget);
});
testWidgets("the studyset's name, cut short on a phone, shows whole on a "
'long press', (tester) async {
await cards.create(
'japanese',
CardContent(front: paragraph('猫'), back: paragraph('cat')),
);
await study(tester, width: 360);
await tester.longPress(find.text('Japanese'));
await tester.pumpAndSettle();
expect(find.byTooltip('Japanese'), findsOneWidget);
expect(find.text('Japanese'), findsNWidgets(2));
});
testWidgets('a tap on the card shows the answer, and another hides it', (
tester,
) async {

View file

@ -10,6 +10,10 @@ 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/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/progress.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_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';
@ -51,6 +55,8 @@ void main() {
late CardRepository cards;
late TagRepository tags;
late ReviewRepository reviews;
late LearningPathRepository paths;
late PathProgressRepository pathProgress;
late String japanese;
late String neko;
late String inu;
@ -69,7 +75,8 @@ void main() {
cardGateway = _CountingCardGateway();
studysets = StudysetRepository(FakeStudysetGateway());
cards = CardRepository(cardGateway);
final paths = LearningPathRepository(FakeLearningPathGateway());
paths = LearningPathRepository(FakeLearningPathGateway());
pathProgress = PathProgressRepository(FakePathAnswerGateway());
tags = TagRepository(
FakeTagGateway(),
cardChanges: cards.changes,
@ -105,7 +112,7 @@ void main() {
tags: tags,
reviews: reviews,
paths: paths,
pathProgress: PathProgressRepository(FakePathAnswerGateway()),
pathProgress: pathProgress,
pdfs: PdfSourceRepository(FakePdfSourceGateway(), assets),
studysetId: japanese,
);
@ -168,6 +175,57 @@ void main() {
}, initialTime: today);
});
test('a learning path tagged with a level offers its plan and counts '
'towards it, as cards do; the cards due are counted', () {
fakeAsync((async) {
start(async, choice: exam);
expect(cubit.state.progress?.due, 0);
run(async, () async {
final particles = await paths.createPath(
japanese,
LearningPathContent.titled('Particles'),
);
final step = await paths.createStep(
particles,
StepKind.info,
StepContent.empty(StepKind.info),
);
await paths.createStep(
particles,
StepKind.info,
StepContent.empty(StepKind.info),
);
await pathProgress.record(step.stepId, AnswerOutcome.continued);
await tags.setTagsOfPath(particles, {await tags.create('JLPT-N4')});
// An untagged path outside the plan counts for nothing.
await paths.createPath(japanese, LearningPathContent.titled('Kanji'));
});
review(async, neko);
async.elapse(ExamCubit.settle);
expect(
cubit.state.plans?.map((plan) => plan.name),
['JLPT N5', 'JLPT N4'],
);
expect(cubit.state.progress?.steps, 0, reason: 'N5 takes no N4 path');
run(async, () => cubit.choose('auto:jlpt:2', exam.date));
async.elapse(ExamCubit.settle);
expect(cubit.state.progress?.steps, 2);
expect(cubit.state.progress?.stepsDone, 1);
// Studied once, 猫 is due again three days on; choosing the exam
// again reads it anew.
async.elapse(const Duration(days: 3));
run(async, () => cubit.choose(exam.plan, exam.date));
async.elapse(ExamCubit.settle);
expect(cubit.state.progress?.due, 1);
unawaited(cubit.close());
async.flushMicrotasks();
}, initialTime: today);
});
test('reads the plans again once a change of tags has settled', () {
fakeAsync((async) {
start(async, choice: exam);

View file

@ -0,0 +1,168 @@
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/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/progress.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.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 'package:halkyon_learn_flutter/src/feature/studyset/bloc/material_progress_cubit.dart';
import '../../../support/fake_asset_gateway.dart';
import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/fake_learning_path_gateway.dart';
import '../../../support/fake_path_answer_gateway.dart';
import '../../../support/fake_pdf_source_gateway.dart';
import '../../../support/memory_asset_file_cache.dart';
void main() {
late LearningPathRepository paths;
late PathProgressRepository progress;
late FakePdfSourceGateway pdfGateway;
late PdfSourceRepository pdfs;
setUp(() {
paths = LearningPathRepository(FakeLearningPathGateway());
progress = PathProgressRepository(FakePathAnswerGateway());
pdfGateway = FakePdfSourceGateway();
pdfs = PdfSourceRepository(
pdfGateway,
AssetRepository(
FakeAssetGateway(),
FakeAssetServerGateway(),
MemoryAssetFileCache(),
),
);
addTearDown(paths.dispose);
addTearDown(progress.dispose);
addTearDown(pdfs.dispose);
});
/// A path in [studysetId] with [steps] steps, the first [done] of them
/// done.
Future<void> path(
String studysetId, {
required int steps,
int done = 0,
}) async {
final path = await paths.createPath(
studysetId,
LearningPathContent.titled('Path'),
);
for (var i = 0; i < steps; i++) {
final step = await paths.createStep(
path,
StepKind.info,
StepContent.empty(StepKind.info),
);
if (i < done) {
await progress.record(step.stepId, AnswerOutcome.continued);
}
}
}
/// A PDF [id] in [studysetId] of [pages] pages, read to [read].
Future<void> pdf(
String id,
String studysetId, {
int? pages,
int read = 0,
}) async {
pdfGateway.receiveRemote(
id: id,
studysetId: studysetId,
payload: encodePdfSourcePayload(
PdfSourceContent(
title: id,
fileName: '$id.pdf',
assetId: 'asset-$id',
pageCount: pages,
),
),
);
if (read > 0) {
await pdfs.reached(id, read);
}
}
/// What the cubit for [studysetIds] reads first, and the cubit.
Future<(MaterialProgressState, MaterialProgressCubit)> read(
List<String> studysetIds,
) async {
final cubit = MaterialProgressCubit(paths, progress, pdfs, studysetIds);
addTearDown(cubit.close);
final first = (await cubit.stream.firstWhere((state) => state != null))!;
return (first, cubit);
}
test('counts the steps done and the paths finished across the studyset '
'and its topics, and no other studyset', () async {
await path('japanese', steps: 3, done: 1);
await path('grammar', steps: 2, done: 2);
await path('french', steps: 5, done: 5);
final (progress, _) = await read(['japanese', 'grammar']);
expect(progress.steps, 5);
expect(progress.stepsDone, 3);
expect(progress.paths, 2);
expect(progress.pathsDone, 1);
});
test('lists the PDFs not read to their last page, those begun first, '
'else in the order of the studysets', () async {
await pdf('genki', 'japanese', pages: 300);
await pdf('done', 'japanese', pages: 10, read: 10);
await pdf('tobira', 'grammar', pages: 200, read: 12);
// Never opened: how many pages it has is unknown.
await pdf('unopened', 'grammar');
await pdf('french', 'french', pages: 5, read: 1);
final (progress, _) = await read(['japanese', 'grammar']);
expect(progress.pdfs, 4);
expect(
[
for (final (:pdf, :furthestPage) in progress.unfinished)
(pdf.id, furthestPage),
],
[('tobira', 12), ('genki', 0), ('unopened', 0)],
);
});
test('reads again as pages are read and steps done', () async {
await pdf('genki', 'japanese', pages: 2);
await path('japanese', steps: 1);
final (_, cubit) = await read(['japanese']);
await pdfs.reached('genki', 2);
await expectLater(
cubit.stream,
emitsThrough(
isA<MaterialProgressState>().having(
(p) => p.unfinished,
'unfinished',
isEmpty,
),
),
);
final step = (await paths.stepsOf(
(await paths.pathsOf('japanese')).single.id,
)).single;
await progress.record(step.id, AnswerOutcome.continued);
await expectLater(
cubit.stream,
emitsThrough(
isA<MaterialProgressState>().having(
(p) => p.pathsDone,
'paths done',
1,
),
),
);
});
}

View file

@ -0,0 +1,46 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/exam_coverage.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
void main() {
ExamProgress progress({
int newCards = 0,
int startedToday = 0,
int due = 0,
}) => (
cards: 100,
newCards: newCards,
startedToday: startedToday,
due: due,
steps: 0,
stepsDone: 0,
pages: 0,
pagesRead: 0,
);
const ExamPace pace = (
daysLeft: 30,
newPerDay: 5,
stepsPerWeek: 0,
pagesPerDay: 0,
);
test('studying for the exam takes every review due and the new cards '
'the pace leaves for the day', () {
expect(examCardsToStudy(progress(newCards: 40, due: 12), pace), 17);
expect(
examCardsToStudy(progress(newCards: 40, startedToday: 3, due: 2), pace),
4,
);
expect(
examCardsToStudy(progress(newCards: 40, startedToday: 7), pace),
0,
reason: 'more started today than the pace asks',
);
expect(
examCardsToStudy(progress(newCards: 2, due: 1), pace),
3,
reason: 'no more new cards than there are',
);
});
}

View file

@ -152,6 +152,17 @@ void main() {
expect(syncs, before + 2);
});
testWidgets('an empty library is pulled down to sync too', (tester) async {
await showLibrary(tester);
expect(find.text('No studysets yet'), findsOneWidget);
final before = syncs;
await tester.flingFrom(const Offset(600, 600), const Offset(0, 400), 1000);
await tester.pumpAndSettle();
expect(syncs, before + 1);
});
testWidgets('creates a studyset with a secondary title and a colour', (
tester,
) async {

View file

@ -21,10 +21,14 @@ import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/progress.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_runner_screen.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_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/pdf_reader_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_progress.dart';
@ -45,6 +49,7 @@ import '../../../support/fake_auth_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/pump_app.dart';
import '../../../support/test_dependencies.dart';
@ -71,9 +76,11 @@ void main() {
late FakeCardGateway cardGateway;
late FakeLearningPathGateway pathGateway;
late FakeNoteGateway noteGateway;
late FakePdfSourceGateway pdfGateway;
setUp(() {
noteGateway = FakeNoteGateway();
pdfGateway = FakePdfSourceGateway();
gateway = FakeStudysetGateway();
cardGateway = FakeCardGateway();
pathGateway = FakeLearningPathGateway();
@ -98,6 +105,7 @@ void main() {
cardGateway: cardGateway,
pathGateway: pathGateway,
noteGateway: noteGateway,
pdfGateway: pdfGateway,
)
: testDependencies(authGateway: auth),
location: location,
@ -1867,6 +1875,12 @@ void main() {
await tester.pumpAndSettle();
expect(find.byType(StudysetScreen), findsNothing);
expect(find.text('Japanese'), findsNothing);
expect(find.text('Signed out'), findsOneWidget);
await tester.tap(find.text('Start without an account'));
await tester.pumpAndSettle();
expect(find.text('Japanese'), findsNothing);
expect(find.text('No studysets yet'), findsOneWidget);
});
@ -2025,6 +2039,72 @@ void main() {
expect(find.text('1 min'), findsOneWidget);
});
testWidgets('Progress also counts the steps of the learning paths done, '
'and lists the PDFs not read to their end, begun first, across the '
'topics; one opens where it is read', (tester) async {
stored('japanese', 'Japanese');
stored('grammar', 'Grammar', parent: 'japanese');
final paths = LearningPathRepository(pathGateway);
final path = await paths.createPath(
'grammar',
LearningPathContent.titled('Particles'),
);
final step = await paths.createStep(
path,
StepKind.info,
StepContent.empty(StepKind.info),
);
await paths.createStep(
path,
StepKind.info,
StepContent.empty(StepKind.info),
);
void pdf(String id, String studysetId, String title, int pages) =>
pdfGateway.receiveRemote(
id: id,
studysetId: studysetId,
payload: encodePdfSourcePayload(
PdfSourceContent(
title: title,
fileName: '$title.pdf',
assetId: 'asset-$id',
pageCount: pages,
),
),
);
pdf('genki', 'japanese', 'Genki I', 300);
pdf('tobira', 'grammar', 'Tobira', 200);
pdf('kana', 'grammar', 'Kana chart', 1);
await showApp(tester, location: RoutePaths.studyset('japanese'));
final person = PersonScope.of(
tester.element(find.byType(StudysetScreen)),
);
await person.pathProgress.record(step.stepId, AnswerOutcome.continued);
await person.pdfSources.reached('tobira', 12);
await person.pdfSources.reached('kana', 1);
await tester.pumpAndSettle();
await openPart(tester, 'Progress');
expect(find.text('Learning paths'), findsOneWidget);
expect(find.text('of 2 steps done'), findsOneWidget);
expect(find.text('0 of 1 path finished'), findsOneWidget);
expect(find.text('Reading'), findsOneWidget);
expect(find.text('Page 12 of 200'), findsOneWidget);
expect(find.text('Not begun yet'), findsOneWidget);
// Read to its end, it is no longer listed.
expect(find.text('Kana chart'), findsNothing);
expect(
tester.getTopLeft(find.text('Tobira')).dy,
lessThan(tester.getTopLeft(find.text('Genki I')).dy),
);
await tester.tap(find.text('Tobira'));
await tester.pumpAndSettle();
expect(find.byType(PdfReaderScreen), findsOneWidget);
});
group('Preparing for an exam', () {
testWidgets('a plan offered from level tags is chosen with a date, and '
'the study plan shows how far the exam is, what it covers and the '
@ -2060,6 +2140,13 @@ void main() {
expect(find.text('0 of 2 flashcards started'), findsOneWidget);
expect(find.text('1 new flashcard a day'), findsOneWidget);
expect(find.text('Study for the exam'), findsOneWidget);
// Next up studies for the exam first: one new card at its pace.
final forExam = find.text('Study 1 flashcard for JLPT N5');
expect(forExam, findsOneWidget);
expect(
tester.getTopLeft(forExam).dy,
lessThan(tester.getTopLeft(find.text('Study 3 flashcards')).dy),
);
final japanese = (await person.studysets.all()).singleWhere(
(studyset) => studyset.id == 'japanese',
);

View file

@ -0,0 +1,14 @@
import 'package:halkyon_learn_flutter/src/feature/auth/data/signed_out_mark.dart';
/// [SignedOutMark] in memory.
class MemorySignedOutMark([var bool _signedOut = false])
implements SignedOutMark {
/// Whether the device is marked signed out now.
bool get signedOut => _signedOut;
@override
Future<bool> read() async => _signedOut;
@override
Future<void> write({required bool signedOut}) async => _signedOut = signedOut;
}

View file

@ -30,6 +30,7 @@ import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/sign_in_addresses.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/signed_out_mark.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
@ -115,6 +116,7 @@ import 'memory_device_settings_store.dart';
import 'memory_pomodoro_run_store.dart';
import 'memory_published_store.dart';
import 'memory_sign_in_addresses.dart';
import 'memory_signed_out_mark.dart';
import 'memory_studyset_tab_store.dart';
/// The app's dependencies with fakes instead of the server and the device's
@ -133,6 +135,7 @@ import 'memory_studyset_tab_store.dart';
/// the person asks; never on timers, which a widget test would find pending.
Dependencies testDependencies({
AuthGateway? authGateway,
SignedOutMark? signedOut,
SignInAddresses? signInAddresses,
StudysetGateway? studysetGateway,
CardGateway? cardGateway,
@ -187,6 +190,7 @@ Dependencies testDependencies({
authGateway ?? FakeAuthGateway(),
signInAddresses ?? MemorySignInAddresses(),
),
signedOut: signedOut ?? MemorySignedOutMark(),
about: AboutRepository(aboutGateway ?? FakeAboutGateway()),
dictionaries: DictionaryRepository(
dictionaryGateway ?? FakeDictionaryGateway(),