feat(learn): a studyset's subject, picked from a fixed list (ADR 0041)

A top studyset is filed under one of 44 subjects in six groups (the
developer's decisions of 9 and 10 Nov 2026): languages as one, computing
in five, Other / not sure last. Each has a Lucide icon and a default
colour, which picking it gives the studyset, its own to change from then
on. The studyset's sheet picks it (pickSubject); a sub-studyset shows its
top studyset's (subjectOf) and offers none. The payload keeps it as
`subject` (schema 8), so archives and published studysets carry it; an
ID this version doesn't know is kept and reads as none.

Whats-New: A studyset can now be filed under a subject, which gives it that subject's colour.
Whats-New-De: Ein Lernset lässt sich jetzt einem Fach zuordnen, das ihm dessen Farbe gibt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-10 18:37:17 +01:00
commit 9868dc27c6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 1766 additions and 10 deletions

View file

@ -3,6 +3,7 @@ import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
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 'package:halkyon_learn_flutter/src/feature/studyset/model/subject.dart';
Studyset studyset(
String id, {
@ -196,6 +197,32 @@ void main() {
expect(colorNameOf('german', all), isNull);
});
test("a studyset's subject is its top studyset's, which alone has one "
"(the developer's decision of 10 Nov 2026)", () {
Studyset of(String id, {String? subject, String? parent}) => Studyset(
id: id,
content: StudysetContent(
title: id,
description: RichContent.empty(),
subject: subject,
),
parentId: parent,
);
final all = [
of('japanese', subject: 'languages'),
of('kana', parent: 'japanese'),
// Left from an import: a sub-studyset's own subject counts for nothing.
of('hiragana', subject: 'music', parent: 'kana'),
of('origami', subject: 'paper-folding'),
of('notes'),
];
expect(subjectOf('hiragana', all), Subject.languages);
expect(subjectOf('japanese', all), Subject.languages);
expect(subjectOf('origami', all), isNull, reason: 'one of a newer app');
expect(subjectOf('notes', all), isNull);
});
test('a studyset without languages of its own takes those of the nearest '
'studyset above it that gives any, both together', () {
Studyset speaking(

View file

@ -0,0 +1,93 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/subject.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// The subjects a studyset is filed under (the developer's decisions of 9
/// and 10 Nov 2026): a fixed list, each with its icon and default colour.
void main() {
test('the IDs studysets keep are these, never renamed: a renamed one '
'would read as one of a newer app', () {
expect(
[for (final subject in Subject.values) subject.id],
[
'biology',
'chemistry',
'physics',
'astronomy',
'earth-sciences',
'environmental-science',
'medicine',
'nursing',
'pharmacy',
'nutrition',
'psychology',
'sports-science',
'mathematics',
'statistics',
'computer-science',
'software-engineering',
'data-science-ai',
'it-operations',
'security-privacy',
'engineering',
'architecture',
'history',
'geography',
'politics',
'law',
'economics',
'business',
'marketing',
'accounting-finance',
'sociology',
'anthropology',
'education',
'media-studies',
'philosophy',
'religious-studies',
'languages',
'literature',
'linguistics',
'art-design',
'music',
'theatre-film',
'cooking-hospitality',
'driving-licences',
'other',
],
);
});
test('read back by ID; one it does not know is none', () {
expect(Subject.byId('languages'), Subject.languages);
expect(Subject.byId('paper-folding'), isNull);
expect(Subject.byId(null), isNull);
});
test('every group holds some, in the order the list has them; Other, '
'without a colour of its own, comes last', () {
for (final group in SubjectGroup.values) {
expect(Subject.values.where((s) => s.group == group), isNotEmpty);
}
expect(Subject.values.last, Subject.other);
expect(Subject.other.color, isNull);
expect(Subject.languages.color, PaletteColor.cerise);
});
test('every subject and group is named in English and German, each name '
'its own', () async {
for (final locale in AppLocalizations.supportedLocales) {
final l10n = await AppLocalizations.delegate.load(locale);
final names = {for (final s in Subject.values) s.nameIn(l10n)};
expect(names, hasLength(Subject.values.length), reason: '$locale');
expect(
{for (final g in SubjectGroup.values) g.nameIn(l10n)},
hasLength(SubjectGroup.values.length),
);
}
final german = await AppLocalizations.delegate.load(const Locale('de'));
expect(Subject.computerScience.nameIn(german), 'Informatik');
});
}