chore: Halkyon Learn moves into apps/learn

Its server, client, app and curator, and the tools that write its
assets (maps, Anki fixtures, icons), as they are; paths are fixed in
the commits that follow.

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-19 20:08:06 +02:00
commit 649499be23
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,226 @@
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.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/home/bloc/home_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.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/progress.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.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/fake_card_gateway.dart';
import '../../../support/fake_path_answer_gateway.dart';
import '../../../support/fake_review_gateway.dart';
import '../../../support/fake_settings_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
void main() {
late StudysetRepository studysets;
late CardRepository cards;
late ReviewRepository reviews;
late SettingsRepository settings;
late PathProgressRepository pathProgress;
late HomeBloc bloc;
// A Wednesday afternoon, local time.
final now = DateTime(2026, 10, 7, 15);
final today = DateTime(2026, 10, 7);
setUp(() {
studysets = StudysetRepository(FakeStudysetGateway());
cards = CardRepository(FakeCardGateway());
reviews = ReviewRepository(FakeReviewGateway());
settings = SettingsRepository(FakeSettingsGateway());
pathProgress = PathProgressRepository(FakePathAnswerGateway());
});
tearDown(() async {
await bloc.close();
await studysets.dispose();
await cards.dispose();
await reviews.dispose();
await settings.dispose();
await pathProgress.dispose();
});
CardContent card() =>
CardContent(front: RichContent.empty(), back: RichContent.empty());
void start() =>
bloc = HomeBloc(studysets, cards, reviews, settings, pathProgress)
..add(const HomeEventStarted());
Future<HomeStateLoaded> showing(bool Function(HomeStateLoaded) test) async =>
await bloc.stream.firstWhere((s) => s is HomeStateLoaded && test(s))
as HomeStateLoaded;
test('a step of a path done counts as activity, and keeps the '
'streak', () async {
await withClock(Clock.fixed(now), () async {
await withClock(
Clock.fixed(now.subtract(const Duration(days: 1))),
() => pathProgress.record('step-1', AnswerOutcome.continued),
);
start();
final yesterday = await showing((s) => s.streak == 1);
expect(yesterday.activity.on(today), 0);
await pathProgress.record('step-2', AnswerOutcome.continued);
await pathProgress.record('step-3', AnswerOutcome.wrong);
final both = await showing((s) => s.streak == 2);
expect(both.activity.on(today), 1);
expect(both.goalProgress, 0);
});
});
test('counts ratings and cards written, keeps the streak and the goal, '
'and follows new ratings', () async {
await withClock(Clock.fixed(now), () async {
final japanese = await studysets.create(
StudysetContent.titled('Japanese'),
);
final neko = await cards.create(japanese, card());
await reviews.record(
cardId: neko,
rating: Rating.good,
reviewedAt: now.subtract(const Duration(days: 1)),
duration: Duration.zero,
);
await settings.update(
(s) => s.withGoal(const StudyGoal(period: GoalPeriod.day, target: 5)),
);
start();
// Written today, rated yesterday: two days in a row.
final first = await showing((s) => s.goal != null);
expect(first.streak, 2);
expect(first.activity.on(today), 1);
expect(first.goalProgress, 0);
await reviews.record(
cardId: neko,
rating: Rating.easy,
reviewedAt: now,
duration: Duration.zero,
);
final rated = await showing((s) => s.goalProgress == 1);
expect(rated.activity.on(today), 2);
});
});
test('imported cards are no writing of the day', () async {
await withClock(Clock.fixed(now), () async {
final japanese = await studysets.create(
StudysetContent.titled('Japanese'),
);
await cards.createAll(japanese, [card(), card()], origin: 'anki');
await cards.create(japanese, card());
start();
final state = await showing((s) => true);
expect(state.activity.on(today), 1);
});
});
test('an import alone starts the activity, without studying', () async {
await withClock(Clock.fixed(now), () async {
final japanese = await studysets.create(
StudysetContent.titled('Japanese'),
);
await cards.createAll(japanese, [card()], origin: 'anki');
start();
final state = await showing((s) => true);
expect(state.activity.start, today);
expect(state.activity.on(today), 0);
});
});
test('cards fall due on their day, but those resting in an archived '
'studyset', () async {
await withClock(Clock.fixed(now), () async {
final japanese = await studysets.create(
StudysetContent.titled('Japanese'),
);
final german = await studysets.create(StudysetContent.titled('German'));
for (final studyset in [japanese, german]) {
final id = await cards.create(studyset, card());
await reviews.record(
cardId: id,
rating: Rating.easy,
reviewedAt: now,
duration: Duration.zero,
);
}
await studysets.archive(german);
start();
final state = await showing((s) => s.due.isNotEmpty);
expect(state.due.values.fold(0, (a, b) => a + b), 1);
});
});
test('a rest day keeps the streak, and toggles off again', () async {
await withClock(Clock.fixed(now), () async {
final japanese = await studysets.create(
StudysetContent.titled('Japanese'),
);
final neko = await cards.create(japanese, card());
await reviews.record(
cardId: neko,
rating: Rating.good,
reviewedAt: now.subtract(const Duration(days: 2)),
duration: Duration.zero,
);
start();
// Written today, rated two days ago: yesterday breaks it.
expect((await showing((s) => true)).streak, 1);
final yesterday = DateTime(2026, 10, 6);
bloc.add(HomeEventRestDayToggled(yesterday));
expect((await showing((s) => s.restDays.isNotEmpty)).streak, 3);
bloc.add(HomeEventRestDayToggled(yesterday));
expect((await showing((s) => s.restDays.isEmpty)).streak, 1);
});
});
test('saves the goal and the layout in the settings', () async {
start();
await showing((s) => true);
bloc
..add(
const HomeEventGoalSet(StudyGoal(period: GoalPeriod.week, target: 50)),
)
..add(
const HomeEventLayoutChanged(
HomeLayout(
order: [
HomeWidget.week,
HomeWidget.goal,
HomeWidget.pomodoro,
HomeWidget.activity,
HomeWidget.deadlines,
],
hidden: {
HomeWidget.pomodoro,
HomeWidget.activity,
HomeWidget.deadlines,
},
),
),
);
final state = await showing(
(s) => s.goal != null && s.layout.hidden.isNotEmpty,
);
expect(state.layout.shown, [HomeWidget.week, HomeWidget.goal]);
expect((await settings.read()).goal?.target, 50);
});
}

View file

@ -0,0 +1,128 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_activity.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.dart';
void main() {
// A Wednesday, in the afternoon, local time.
final now = DateTime(2026, 10, 7, 15);
DateTime day(int d) => DateTime(2026, 10, d);
DateTime at(int d, [int hour = 10]) => DateTime(2026, 10, d, hour);
StudyActivity studied(List<int> days) =>
StudyActivity.of(ratings: [for (final d in days) at(d)], written: []);
test('counts ratings and cards written by the local day they fell on', () {
final activity = StudyActivity.of(
ratings: [at(5, 9), at(5, 23)],
written: [at(6)],
);
expect(activity.on(day(5)), 2);
expect(activity.on(day(6)), 1);
expect(activity.studiedOn(day(4)), isFalse);
});
test('starts on the first day anything happened, imports included, which '
"don't count as studying", () {
final activity = StudyActivity.of(
ratings: [at(6)],
written: [at(5)],
imported: [at(4, 23)],
);
expect(activity.start, day(4));
expect(activity.studiedOn(day(4)), isFalse);
expect(StudyActivity.none.start, isNull);
});
group('The streak', () {
test('runs up to today once studied today', () {
expect(studyStreak(studied([5, 6, 7]), {}, now), 3);
});
test('runs up to yesterday while today is still open', () {
expect(studyStreak(studied([5, 6]), {}, now), 2);
});
test('is broken by a day without studying or resting', () {
expect(studyStreak(studied([4, 6, 7]), {}, now), 2);
expect(studyStreak(studied([4, 5]), {}, now), 0);
});
test('keeps going over a rest day, which counts', () {
expect(studyStreak(studied([4, 6, 7]), {day(5)}, now), 4);
});
});
test('a week runs Monday to Sunday, each day as it stands', () {
final week = weekOf(day(7), studied([5, 7]), {day(6)}, now);
expect(week.map((each) => each.day.day), [5, 6, 7, 8, 9, 10, 11]);
expect(week.map((each) => each.state), [
DayState.studied,
DayState.rested,
DayState.studied,
DayState.upcoming,
DayState.upcoming,
DayState.upcoming,
DayState.upcoming,
]);
expect(
weekOf(day(7), studied([5]), {}, now).take(3).map((each) => each.state),
[DayState.studied, DayState.missed, DayState.today],
);
});
test('days before the first activity have nothing to miss; a rest day '
'still shows', () {
expect(
weekOf(day(7), studied([6]), {}, now).take(3).map((each) => each.state),
[DayState.notStarted, DayState.studied, DayState.today],
);
expect(
weekOf(day(7), studied([]), {day(5)}, now).take(3).map((e) => e.state),
[DayState.rested, DayState.notStarted, DayState.today],
);
});
test('heat starts at one card and darkens in coarse steps', () {
expect([0, 1, 4, 5, 9, 10, 19, 20].map(heatLevel), [
0,
1,
1,
2,
2,
3,
3,
4,
]);
});
test('cards fall due on their day; overdue ones today', () {
expect(dueByDay([at(1), at(7), at(9), at(9, 20)], day(7)), {
day(7): 2,
day(9): 2,
});
});
test("a goal's progress counts the ratings of its day or week", () {
final ratings = [at(4), at(5), at(6), at(7), at(7, 14)];
expect(
goalProgress(
const StudyGoal(period: GoalPeriod.day, target: 20),
ratings,
now,
),
2,
);
expect(
goalProgress(
const StudyGoal(period: GoalPeriod.week, target: 20),
ratings,
now,
),
4,
);
});
}

View file

@ -0,0 +1,270 @@
import 'package:flutter_test/flutter_test.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/home/widget/goal_card.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/streak.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_card.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_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_review_gateway.dart';
import '../../../support/fake_settings_gateway.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
void main() {
late FakeCardGateway cardGateway;
late FakeReviewGateway reviewGateway;
late FakeSettingsGateway settingsGateway;
setUp(() {
cardGateway = FakeCardGateway();
reviewGateway = FakeReviewGateway();
settingsGateway = FakeSettingsGateway();
});
Future<void> showHome(WidgetTester tester, {double width = 1200}) async {
tester.view.physicalSize = Size(width, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
cardGateway: cardGateway,
reviewGateway: reviewGateway,
settingsGateway: settingsGateway,
),
);
}
int streak(WidgetTester tester) =>
tester.widget<StreakChip>(find.byType(StreakChip)).streak;
/// Rates a new card now.
Future<void> rateToday() async {
final cards = CardRepository(cardGateway);
addTearDown(cards.dispose);
final id = await cards.create(
'japanese',
CardContent(front: RichContent.empty(), back: RichContent.empty()),
);
await ReviewRepository(reviewGateway).record(
cardId: id,
rating: Rating.good,
reviewedAt: DateTime.now(),
duration: Duration.zero,
);
}
testWidgets('cobalt washes down from the top, behind a clear app bar', (
tester,
) async {
await showHome(tester);
expect(
tester.widget<TopGradient>(find.byType(TopGradient)).color,
Brand.primary,
);
expect(
tester.widget<AppBar>(find.byType(AppBar)).backgroundColor,
Colors.transparent,
);
});
testWidgets('opens with the goal first, then the Pomodoro timer, each on '
'a card', (tester) async {
await showHome(tester);
expect(
tester.getRect(find.byType(GoalCard)).top,
lessThan(tester.getRect(find.byType(PomodoroCard)).top),
);
expect(
find.ancestor(of: find.byType(GoalCard), matching: find.byType(Card)),
findsNothing,
);
expect(
find.descendant(of: find.byType(GoalCard), matching: find.byType(Card)),
findsOneWidget,
);
});
testWidgets('the streak sheet: the count inside the flame, the week as '
'letters, and what keeps it going', (tester) async {
await rateToday();
await showHome(tester, width: 400);
await tester.tap(find.byType(StreakChip));
await tester.pumpAndSettle();
final sheet = find.byType(BottomSheet);
expect(
find.descendant(of: sheet, matching: find.text('1 day streak')),
findsOneWidget,
);
expect(
find.descendant(of: sheet, matching: find.text('1')),
findsOneWidget,
);
expect(
find.descendant(of: sheet, matching: find.text('M')),
findsOneWidget,
);
expect(
find.descendant(
of: sheet,
matching: find.text('Come back tomorrow to keep your streak.'),
),
findsOneWidget,
);
});
testWidgets('the week names the day with its month short, and the '
"heatmap's days say how much was done", (tester) async {
await showHome(tester);
final now = DateTime.now();
expect(
find.text(
'${DateFormat.EEEE('en_US').format(now)}, '
'${DateFormat.MMMd('en_US').format(now)}',
),
findsOneWidget,
);
expect(
find.byWidgetPredicate(
(widget) =>
widget is Tooltip &&
(widget.message?.startsWith('0 activities · ') ?? false),
),
findsWidgets,
);
});
testWidgets('a card rated today lights the streak and the heatmap', (
tester,
) async {
await rateToday();
await showHome(tester);
expect(streak(tester), 1);
// The card written and the rating.
expect(find.text('2 activities in the last year'), findsOneWidget);
});
testWidgets('a goal is set in its sheet and shows its progress', (
tester,
) async {
await showHome(tester);
await tester.tap(find.text('Set a study goal'));
await tester.pumpAndSettle();
await tester.tap(find.text('Per week'));
await tester.pump();
await tester.tap(find.byTooltip('Increase'));
await tester.pump();
await tester.tap(find.text('Save goal'));
await tester.pumpAndSettle();
expect(find.text('Weekly goal'), findsOneWidget);
// In steps of 5, as the study options.
expect(find.text('0 of 25 cards this week'), findsOneWidget);
expect(find.byIcon(LucideIcons.pencil), findsOneWidget);
await tester.tap(find.text('Weekly goal'));
await tester.pumpAndSettle();
// Removing sits below saving.
expect(
tester.getRect(find.text('Remove goal')).top,
greaterThan(tester.getRect(find.text('Save goal')).bottom),
);
});
testWidgets('resting today keeps the streak', (tester) async {
await showHome(tester);
expect(streak(tester), 0);
await tester.tap(find.text('Rest day'));
await tester.pumpAndSettle();
expect(streak(tester), 1);
});
testWidgets("the rest day sits in the week's header, beside the day "
'shown', (tester) async {
await showHome(tester);
final restDay = tester.getRect(find.text('Rest day'));
final previous = tester.getRect(find.byTooltip('Previous day'));
expect(restDay.center.dy, closeTo(previous.center.dy, 2));
expect(restDay.right, lessThan(previous.left));
});
testWidgets('on a phone, the rest day is a snowflake button in the '
"week's header", (tester) async {
await showHome(tester, width: 360);
expect(find.text('Rest day'), findsNothing);
final restDay = find.byTooltip('Rest day');
expect(
tester.getRect(restDay).center.dy,
closeTo(tester.getRect(find.byTooltip('Previous day')).center.dy, 2),
);
await tester.tap(restDay);
await tester.pumpAndSettle();
expect(streak(tester), 1);
});
testWidgets('a long press arranges the dashboard: a widget hidden stays '
'hidden', (tester) async {
await showHome(tester);
await tester.longPress(find.text('Set a study goal'));
await tester.pumpAndSettle();
expect(find.text('Arrange widgets'), findsOneWidget);
await tester.tap(find.byTooltip('Hide Daily goal'));
await tester.pumpAndSettle();
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(find.text('Set a study goal'), findsNothing);
expect(find.text('Your activity'), findsOneWidget);
});
testWidgets('with every widget hidden, Customise brings the arranging '
'back', (tester) async {
await showHome(tester);
await tester.longPress(find.text('Set a study goal'));
await tester.pumpAndSettle();
for (final name in [
'Daily goal',
'Pomodoro',
'PDF reading deadlines',
'Week overview',
'Activity heatmap',
]) {
await tester.tap(find.byTooltip('Hide $name'));
await tester.pumpAndSettle();
}
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(find.text('All widgets are hidden'), findsOneWidget);
expect(
find.text('Bring some back to fill your dashboard.'),
findsOneWidget,
);
// Tests run in American English.
await tester.tap(find.text('Customize'));
await tester.pumpAndSettle();
expect(find.text('Arrange widgets'), findsOneWidget);
});
}