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
20 changed files with 1766 additions and 10 deletions

File diff suppressed because one or more lines are too long

View file

@ -2112,5 +2112,58 @@
"workStagePacking": "Packen", "workStagePacking": "Packen",
"importAborted": "Import abgebrochen: Nichts davon wurde behalten.", "importAborted": "Import abgebrochen: Nichts davon wurde behalten.",
"exportAborted": "Export abgebrochen.", "exportAborted": "Export abgebrochen.",
"exportProgressTitle": "Wird exportiert" "exportProgressTitle": "Wird exportiert",
"subjectGroupSciences": "Naturwissenschaften und Gesundheit",
"subjectGroupComputing": "Mathematik und Informatik",
"subjectGroupSociety": "Gesellschaft",
"subjectGroupArts": "Sprachen und Künste",
"subjectGroupEveryday": "Alltag",
"subjectGroupOther": "Sonstiges",
"subjectBiology": "Biologie",
"subjectChemistry": "Chemie",
"subjectPhysics": "Physik",
"subjectAstronomy": "Astronomie",
"subjectEarthSciences": "Geowissenschaften",
"subjectEnvironmentalScience": "Umweltwissenschaften",
"subjectMedicine": "Medizin",
"subjectNursing": "Pflege",
"subjectPharmacy": "Pharmazie",
"subjectNutrition": "Ernährung",
"subjectPsychology": "Psychologie",
"subjectSportsScience": "Sportwissenschaft",
"subjectMathematics": "Mathematik",
"subjectStatistics": "Statistik",
"subjectComputerScience": "Informatik",
"subjectSoftwareEngineering": "Softwareentwicklung",
"subjectDataScienceAi": "Data Science und KI",
"subjectItOperations": "IT und Betrieb",
"subjectSecurityPrivacy": "Sicherheit und Datenschutz",
"subjectEngineering": "Ingenieurwesen",
"subjectArchitecture": "Architektur",
"subjectHistory": "Geschichte",
"subjectGeography": "Geografie",
"subjectPolitics": "Politik",
"subjectLaw": "Recht",
"subjectEconomics": "Volkswirtschaft",
"subjectBusiness": "Betriebswirtschaft",
"subjectMarketing": "Marketing",
"subjectAccountingFinance": "Rechnungswesen und Finanzen",
"subjectSociology": "Soziologie",
"subjectAnthropology": "Anthropologie",
"subjectEducation": "Pädagogik",
"subjectMediaStudies": "Medienwissenschaft",
"subjectPhilosophy": "Philosophie",
"subjectReligiousStudies": "Religionswissenschaft",
"subjectLanguages": "Sprachen",
"subjectLiterature": "Literatur",
"subjectLinguistics": "Linguistik",
"subjectArtDesign": "Kunst und Design",
"subjectMusic": "Musik",
"subjectTheatreFilm": "Theater und Film",
"subjectCookingHospitality": "Kochen und Gastronomie",
"subjectDrivingLicences": "Führerschein und Lizenzen",
"subjectOther": "Sonstiges / unsicher",
"studysetSheetSubject": "Fach",
"studysetSheetSubjectNone": "Fach wählen",
"subjectPickerTitle": "Fach"
} }

View file

@ -2550,5 +2550,111 @@
"exportAborted": "Export aborted.", "exportAborted": "Export aborted.",
"@exportAborted": {"description": "Snack bar after an export's Abort."}, "@exportAborted": {"description": "Snack bar after an export's Abort."},
"exportProgressTitle": "Exporting", "exportProgressTitle": "Exporting",
"@exportProgressTitle": {"description": "The sheet showing an export's progress."} "@exportProgressTitle": {"description": "The sheet showing an export's progress."},
"subjectGroupSciences": "Sciences and health",
"@subjectGroupSciences": {"description": "The group of subjects \"Sciences and health\" in the subject picker."},
"subjectGroupComputing": "Mathematics and computing",
"@subjectGroupComputing": {"description": "The group of subjects \"Mathematics and computing\" in the subject picker."},
"subjectGroupSociety": "Society",
"@subjectGroupSociety": {"description": "The group of subjects \"Society\" in the subject picker."},
"subjectGroupArts": "Languages and arts",
"@subjectGroupArts": {"description": "The group of subjects \"Languages and arts\" in the subject picker."},
"subjectGroupEveryday": "Everyday",
"@subjectGroupEveryday": {"description": "The group of subjects \"Everyday\" in the subject picker."},
"subjectGroupOther": "Other",
"@subjectGroupOther": {"description": "The group of subjects \"Other\" in the subject picker."},
"subjectBiology": "Biology",
"@subjectBiology": {"description": "The subject \"Biology\" a studyset is filed under."},
"subjectChemistry": "Chemistry",
"@subjectChemistry": {"description": "The subject \"Chemistry\" a studyset is filed under."},
"subjectPhysics": "Physics",
"@subjectPhysics": {"description": "The subject \"Physics\" a studyset is filed under."},
"subjectAstronomy": "Astronomy",
"@subjectAstronomy": {"description": "The subject \"Astronomy\" a studyset is filed under."},
"subjectEarthSciences": "Earth sciences",
"@subjectEarthSciences": {"description": "The subject \"Earth sciences\" a studyset is filed under."},
"subjectEnvironmentalScience": "Environmental science",
"@subjectEnvironmentalScience": {"description": "The subject \"Environmental science\" a studyset is filed under."},
"subjectMedicine": "Medicine",
"@subjectMedicine": {"description": "The subject \"Medicine\" a studyset is filed under."},
"subjectNursing": "Nursing",
"@subjectNursing": {"description": "The subject \"Nursing\" a studyset is filed under."},
"subjectPharmacy": "Pharmacy",
"@subjectPharmacy": {"description": "The subject \"Pharmacy\" a studyset is filed under."},
"subjectNutrition": "Nutrition",
"@subjectNutrition": {"description": "The subject \"Nutrition\" a studyset is filed under."},
"subjectPsychology": "Psychology",
"@subjectPsychology": {"description": "The subject \"Psychology\" a studyset is filed under."},
"subjectSportsScience": "Sports science",
"@subjectSportsScience": {"description": "The subject \"Sports science\" a studyset is filed under."},
"subjectMathematics": "Mathematics",
"@subjectMathematics": {"description": "The subject \"Mathematics\" a studyset is filed under."},
"subjectStatistics": "Statistics",
"@subjectStatistics": {"description": "The subject \"Statistics\" a studyset is filed under."},
"subjectComputerScience": "Computer science",
"@subjectComputerScience": {"description": "The subject \"Computer science\" a studyset is filed under."},
"subjectSoftwareEngineering": "Software engineering",
"@subjectSoftwareEngineering": {"description": "The subject \"Software engineering\" a studyset is filed under."},
"subjectDataScienceAi": "Data science and AI",
"@subjectDataScienceAi": {"description": "The subject \"Data science and AI\" a studyset is filed under."},
"subjectItOperations": "IT and operations",
"@subjectItOperations": {"description": "The subject \"IT and operations\" a studyset is filed under."},
"subjectSecurityPrivacy": "Security and privacy",
"@subjectSecurityPrivacy": {"description": "The subject \"Security and privacy\" a studyset is filed under."},
"subjectEngineering": "Engineering",
"@subjectEngineering": {"description": "The subject \"Engineering\" a studyset is filed under."},
"subjectArchitecture": "Architecture",
"@subjectArchitecture": {"description": "The subject \"Architecture\" a studyset is filed under."},
"subjectHistory": "History",
"@subjectHistory": {"description": "The subject \"History\" a studyset is filed under."},
"subjectGeography": "Geography",
"@subjectGeography": {"description": "The subject \"Geography\" a studyset is filed under."},
"subjectPolitics": "Politics",
"@subjectPolitics": {"description": "The subject \"Politics\" a studyset is filed under."},
"subjectLaw": "Law",
"@subjectLaw": {"description": "The subject \"Law\" a studyset is filed under."},
"subjectEconomics": "Economics",
"@subjectEconomics": {"description": "The subject \"Economics\" a studyset is filed under."},
"subjectBusiness": "Business",
"@subjectBusiness": {"description": "The subject \"Business\" a studyset is filed under."},
"subjectMarketing": "Marketing",
"@subjectMarketing": {"description": "The subject \"Marketing\" a studyset is filed under."},
"subjectAccountingFinance": "Accounting and finance",
"@subjectAccountingFinance": {"description": "The subject \"Accounting and finance\" a studyset is filed under."},
"subjectSociology": "Sociology",
"@subjectSociology": {"description": "The subject \"Sociology\" a studyset is filed under."},
"subjectAnthropology": "Anthropology",
"@subjectAnthropology": {"description": "The subject \"Anthropology\" a studyset is filed under."},
"subjectEducation": "Education",
"@subjectEducation": {"description": "The subject \"Education\" a studyset is filed under."},
"subjectMediaStudies": "Media studies",
"@subjectMediaStudies": {"description": "The subject \"Media studies\" a studyset is filed under."},
"subjectPhilosophy": "Philosophy",
"@subjectPhilosophy": {"description": "The subject \"Philosophy\" a studyset is filed under."},
"subjectReligiousStudies": "Religious studies",
"@subjectReligiousStudies": {"description": "The subject \"Religious studies\" a studyset is filed under."},
"subjectLanguages": "Languages",
"@subjectLanguages": {"description": "The subject \"Languages\" a studyset is filed under."},
"subjectLiterature": "Literature",
"@subjectLiterature": {"description": "The subject \"Literature\" a studyset is filed under."},
"subjectLinguistics": "Linguistics",
"@subjectLinguistics": {"description": "The subject \"Linguistics\" a studyset is filed under."},
"subjectArtDesign": "Art and design",
"@subjectArtDesign": {"description": "The subject \"Art and design\" a studyset is filed under."},
"subjectMusic": "Music",
"@subjectMusic": {"description": "The subject \"Music\" a studyset is filed under."},
"subjectTheatreFilm": "Theatre and film",
"@subjectTheatreFilm": {"description": "The subject \"Theatre and film\" a studyset is filed under."},
"subjectCookingHospitality": "Cooking and hospitality",
"@subjectCookingHospitality": {"description": "The subject \"Cooking and hospitality\" a studyset is filed under."},
"subjectDrivingLicences": "Driving and licences",
"@subjectDrivingLicences": {"description": "The subject \"Driving and licences\" a studyset is filed under."},
"subjectOther": "Other / not sure",
"@subjectOther": {"description": "The subject \"Other / not sure\" a studyset is filed under."},
"studysetSheetSubject": "Subject",
"@studysetSheetSubject": {"description": "The label of the subject field in the studyset sheet."},
"studysetSheetSubjectNone": "Choose a subject",
"@studysetSheetSubjectNone": {"description": "The subject field before one is chosen."},
"subjectPickerTitle": "Subject",
"@subjectPickerTitle": {"description": "The title of the sheet picking a studyset's subject."}
} }

View file

@ -12457,6 +12457,324 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Exporting'** /// **'Exporting'**
String get exportProgressTitle; String get exportProgressTitle;
/// The group of subjects "Sciences and health" in the subject picker.
///
/// In en, this message translates to:
/// **'Sciences and health'**
String get subjectGroupSciences;
/// The group of subjects "Mathematics and computing" in the subject picker.
///
/// In en, this message translates to:
/// **'Mathematics and computing'**
String get subjectGroupComputing;
/// The group of subjects "Society" in the subject picker.
///
/// In en, this message translates to:
/// **'Society'**
String get subjectGroupSociety;
/// The group of subjects "Languages and arts" in the subject picker.
///
/// In en, this message translates to:
/// **'Languages and arts'**
String get subjectGroupArts;
/// The group of subjects "Everyday" in the subject picker.
///
/// In en, this message translates to:
/// **'Everyday'**
String get subjectGroupEveryday;
/// The group of subjects "Other" in the subject picker.
///
/// In en, this message translates to:
/// **'Other'**
String get subjectGroupOther;
/// The subject "Biology" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Biology'**
String get subjectBiology;
/// The subject "Chemistry" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Chemistry'**
String get subjectChemistry;
/// The subject "Physics" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Physics'**
String get subjectPhysics;
/// The subject "Astronomy" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Astronomy'**
String get subjectAstronomy;
/// The subject "Earth sciences" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Earth sciences'**
String get subjectEarthSciences;
/// The subject "Environmental science" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Environmental science'**
String get subjectEnvironmentalScience;
/// The subject "Medicine" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Medicine'**
String get subjectMedicine;
/// The subject "Nursing" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Nursing'**
String get subjectNursing;
/// The subject "Pharmacy" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Pharmacy'**
String get subjectPharmacy;
/// The subject "Nutrition" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Nutrition'**
String get subjectNutrition;
/// The subject "Psychology" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Psychology'**
String get subjectPsychology;
/// The subject "Sports science" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Sports science'**
String get subjectSportsScience;
/// The subject "Mathematics" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Mathematics'**
String get subjectMathematics;
/// The subject "Statistics" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Statistics'**
String get subjectStatistics;
/// The subject "Computer science" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Computer science'**
String get subjectComputerScience;
/// The subject "Software engineering" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Software engineering'**
String get subjectSoftwareEngineering;
/// The subject "Data science and AI" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Data science and AI'**
String get subjectDataScienceAi;
/// The subject "IT and operations" a studyset is filed under.
///
/// In en, this message translates to:
/// **'IT and operations'**
String get subjectItOperations;
/// The subject "Security and privacy" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Security and privacy'**
String get subjectSecurityPrivacy;
/// The subject "Engineering" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Engineering'**
String get subjectEngineering;
/// The subject "Architecture" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Architecture'**
String get subjectArchitecture;
/// The subject "History" a studyset is filed under.
///
/// In en, this message translates to:
/// **'History'**
String get subjectHistory;
/// The subject "Geography" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Geography'**
String get subjectGeography;
/// The subject "Politics" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Politics'**
String get subjectPolitics;
/// The subject "Law" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Law'**
String get subjectLaw;
/// The subject "Economics" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Economics'**
String get subjectEconomics;
/// The subject "Business" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Business'**
String get subjectBusiness;
/// The subject "Marketing" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Marketing'**
String get subjectMarketing;
/// The subject "Accounting and finance" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Accounting and finance'**
String get subjectAccountingFinance;
/// The subject "Sociology" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Sociology'**
String get subjectSociology;
/// The subject "Anthropology" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Anthropology'**
String get subjectAnthropology;
/// The subject "Education" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Education'**
String get subjectEducation;
/// The subject "Media studies" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Media studies'**
String get subjectMediaStudies;
/// The subject "Philosophy" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Philosophy'**
String get subjectPhilosophy;
/// The subject "Religious studies" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Religious studies'**
String get subjectReligiousStudies;
/// The subject "Languages" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Languages'**
String get subjectLanguages;
/// The subject "Literature" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Literature'**
String get subjectLiterature;
/// The subject "Linguistics" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Linguistics'**
String get subjectLinguistics;
/// The subject "Art and design" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Art and design'**
String get subjectArtDesign;
/// The subject "Music" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Music'**
String get subjectMusic;
/// The subject "Theatre and film" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Theatre and film'**
String get subjectTheatreFilm;
/// The subject "Cooking and hospitality" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Cooking and hospitality'**
String get subjectCookingHospitality;
/// The subject "Driving and licences" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Driving and licences'**
String get subjectDrivingLicences;
/// The subject "Other / not sure" a studyset is filed under.
///
/// In en, this message translates to:
/// **'Other / not sure'**
String get subjectOther;
/// The label of the subject field in the studyset sheet.
///
/// In en, this message translates to:
/// **'Subject'**
String get studysetSheetSubject;
/// The subject field before one is chosen.
///
/// In en, this message translates to:
/// **'Choose a subject'**
String get studysetSheetSubjectNone;
/// The title of the sheet picking a studyset's subject.
///
/// In en, this message translates to:
/// **'Subject'**
String get subjectPickerTitle;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -8011,4 +8011,163 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get exportProgressTitle => 'Wird exportiert'; String get exportProgressTitle => 'Wird exportiert';
@override
String get subjectGroupSciences => 'Naturwissenschaften und Gesundheit';
@override
String get subjectGroupComputing => 'Mathematik und Informatik';
@override
String get subjectGroupSociety => 'Gesellschaft';
@override
String get subjectGroupArts => 'Sprachen und Künste';
@override
String get subjectGroupEveryday => 'Alltag';
@override
String get subjectGroupOther => 'Sonstiges';
@override
String get subjectBiology => 'Biologie';
@override
String get subjectChemistry => 'Chemie';
@override
String get subjectPhysics => 'Physik';
@override
String get subjectAstronomy => 'Astronomie';
@override
String get subjectEarthSciences => 'Geowissenschaften';
@override
String get subjectEnvironmentalScience => 'Umweltwissenschaften';
@override
String get subjectMedicine => 'Medizin';
@override
String get subjectNursing => 'Pflege';
@override
String get subjectPharmacy => 'Pharmazie';
@override
String get subjectNutrition => 'Ernährung';
@override
String get subjectPsychology => 'Psychologie';
@override
String get subjectSportsScience => 'Sportwissenschaft';
@override
String get subjectMathematics => 'Mathematik';
@override
String get subjectStatistics => 'Statistik';
@override
String get subjectComputerScience => 'Informatik';
@override
String get subjectSoftwareEngineering => 'Softwareentwicklung';
@override
String get subjectDataScienceAi => 'Data Science und KI';
@override
String get subjectItOperations => 'IT und Betrieb';
@override
String get subjectSecurityPrivacy => 'Sicherheit und Datenschutz';
@override
String get subjectEngineering => 'Ingenieurwesen';
@override
String get subjectArchitecture => 'Architektur';
@override
String get subjectHistory => 'Geschichte';
@override
String get subjectGeography => 'Geografie';
@override
String get subjectPolitics => 'Politik';
@override
String get subjectLaw => 'Recht';
@override
String get subjectEconomics => 'Volkswirtschaft';
@override
String get subjectBusiness => 'Betriebswirtschaft';
@override
String get subjectMarketing => 'Marketing';
@override
String get subjectAccountingFinance => 'Rechnungswesen und Finanzen';
@override
String get subjectSociology => 'Soziologie';
@override
String get subjectAnthropology => 'Anthropologie';
@override
String get subjectEducation => 'Pädagogik';
@override
String get subjectMediaStudies => 'Medienwissenschaft';
@override
String get subjectPhilosophy => 'Philosophie';
@override
String get subjectReligiousStudies => 'Religionswissenschaft';
@override
String get subjectLanguages => 'Sprachen';
@override
String get subjectLiterature => 'Literatur';
@override
String get subjectLinguistics => 'Linguistik';
@override
String get subjectArtDesign => 'Kunst und Design';
@override
String get subjectMusic => 'Musik';
@override
String get subjectTheatreFilm => 'Theater und Film';
@override
String get subjectCookingHospitality => 'Kochen und Gastronomie';
@override
String get subjectDrivingLicences => 'Führerschein und Lizenzen';
@override
String get subjectOther => 'Sonstiges / unsicher';
@override
String get studysetSheetSubject => 'Fach';
@override
String get studysetSheetSubjectNone => 'Fach wählen';
@override
String get subjectPickerTitle => 'Fach';
} }

View file

@ -7938,6 +7938,165 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get exportProgressTitle => 'Exporting'; String get exportProgressTitle => 'Exporting';
@override
String get subjectGroupSciences => 'Sciences and health';
@override
String get subjectGroupComputing => 'Mathematics and computing';
@override
String get subjectGroupSociety => 'Society';
@override
String get subjectGroupArts => 'Languages and arts';
@override
String get subjectGroupEveryday => 'Everyday';
@override
String get subjectGroupOther => 'Other';
@override
String get subjectBiology => 'Biology';
@override
String get subjectChemistry => 'Chemistry';
@override
String get subjectPhysics => 'Physics';
@override
String get subjectAstronomy => 'Astronomy';
@override
String get subjectEarthSciences => 'Earth sciences';
@override
String get subjectEnvironmentalScience => 'Environmental science';
@override
String get subjectMedicine => 'Medicine';
@override
String get subjectNursing => 'Nursing';
@override
String get subjectPharmacy => 'Pharmacy';
@override
String get subjectNutrition => 'Nutrition';
@override
String get subjectPsychology => 'Psychology';
@override
String get subjectSportsScience => 'Sports science';
@override
String get subjectMathematics => 'Mathematics';
@override
String get subjectStatistics => 'Statistics';
@override
String get subjectComputerScience => 'Computer science';
@override
String get subjectSoftwareEngineering => 'Software engineering';
@override
String get subjectDataScienceAi => 'Data science and AI';
@override
String get subjectItOperations => 'IT and operations';
@override
String get subjectSecurityPrivacy => 'Security and privacy';
@override
String get subjectEngineering => 'Engineering';
@override
String get subjectArchitecture => 'Architecture';
@override
String get subjectHistory => 'History';
@override
String get subjectGeography => 'Geography';
@override
String get subjectPolitics => 'Politics';
@override
String get subjectLaw => 'Law';
@override
String get subjectEconomics => 'Economics';
@override
String get subjectBusiness => 'Business';
@override
String get subjectMarketing => 'Marketing';
@override
String get subjectAccountingFinance => 'Accounting and finance';
@override
String get subjectSociology => 'Sociology';
@override
String get subjectAnthropology => 'Anthropology';
@override
String get subjectEducation => 'Education';
@override
String get subjectMediaStudies => 'Media studies';
@override
String get subjectPhilosophy => 'Philosophy';
@override
String get subjectReligiousStudies => 'Religious studies';
@override
String get subjectLanguages => 'Languages';
@override
String get subjectLiterature => 'Literature';
@override
String get subjectLinguistics => 'Linguistics';
@override
String get subjectArtDesign => 'Art and design';
@override
String get subjectMusic => 'Music';
@override
String get subjectTheatreFilm => 'Theatre and film';
@override
String get subjectCookingHospitality => 'Cooking and hospitality';
@override
String get subjectDrivingLicences => 'Driving and licences';
@override
String get subjectOther => 'Other / not sure';
@override
String get studysetSheetSubject => 'Subject';
@override
String get studysetSheetSubjectNone => 'Choose a subject';
@override
String get subjectPickerTitle => 'Subject';
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -673,6 +673,7 @@ class CopyUpdater(
publishedAt: location, publishedAt: location,
examPlans: content.examPlans, examPlans: content.examPlans,
exam: content.exam, exam: content.exam,
subject: content.subject,
); );
static LearningPathContent _pathWithOrigin( static LearningPathContent _pathWithOrigin(

View file

@ -544,6 +544,7 @@ class ReleaseCopier(
publishedAt: location, publishedAt: location,
examPlans: content.examPlans, examPlans: content.examPlans,
exam: content.exam, exam: content.exam,
subject: content.subject,
); );
static LearningPathContent _pathWithOrigin( static LearningPathContent _pathWithOrigin(

View file

@ -24,10 +24,12 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_conten
// added the exams it plans for (`examPlans`: id, name, the topics it covers // added the exams it plans for (`examPlans`: id, name, the topics it covers
// by their ids, the tags by their names; left out when none) and the one the // by their ids, the tags by their names; left out when none) and the one the
// person prepares for (`exam`: the plan's id and the exam's day, // person prepares for (`exam`: the plan's id and the exam's day,
// `YYYY-MM-DD`; theirs alone, never published). // `YYYY-MM-DD`; theirs alone, never published). Schema 8 added the subject
// (`subject`: a Subject's ID, kept even where this app doesn't know it), left
// out when none was chosen.
/// The schema this app writes. /// The schema this app writes.
const studysetSchemaVersion = 7; const studysetSchemaVersion = 8;
/// Encodes a studyset's content into its payload. /// Encodes a studyset's content into its payload.
ByteData encodeStudysetPayload(StudysetContent content) => encodePayload( ByteData encodeStudysetPayload(StudysetContent content) => encodePayload(
@ -60,6 +62,7 @@ ByteData encodeStudysetPayload(StudysetContent content) => encodePayload(
if (content.examPlans.isNotEmpty) if (content.examPlans.isNotEmpty)
'examPlans': [for (final plan in content.examPlans) plan.toJson()], 'examPlans': [for (final plan in content.examPlans) plan.toJson()],
if (content.exam case final exam?) 'exam': exam.toJson(), if (content.exam case final exam?) 'exam': exam.toJson(),
'subject': ?content.subject,
}, },
); );
@ -115,6 +118,7 @@ StudysetContent decodeStudysetPayload(ByteData payload) {
?ExamPlan.fromJson(plan), ?ExamPlan.fromJson(plan),
], ],
exam: ExamChoice.fromJson(json['exam']), exam: ExamChoice.fromJson(json['exam']),
subject: _optional(json, 'subject'),
); );
} }
throw const FormatException('Not a studyset payload.'); throw const FormatException('Not a studyset payload.');

View file

@ -4,6 +4,7 @@ import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart'; import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart'; import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/subject.dart';
/// What a person writes about a studyset: everything its synced payload /// What a person writes about a studyset: everything its synced payload
/// holds. Where it sits and whether it is starred or archived are columns of /// holds. Where it sits and whether it is starred or archived are columns of
@ -63,6 +64,12 @@ final class const StudysetContent({
/// The exam the person prepares for and its date, theirs alone (never /// The exam the person prepares for and its date, theirs alone (never
/// published); null without one. /// published); null without one.
final ExamChoice? exam, final ExamChoice? exam,
/// The ID of the [Subject] it is filed under (decided by the developer,
/// 10 Nov 2026), on a top studyset alone, which its sub-studysets show
/// (`subjectOf`); published with it. Null where none was chosen; one this
/// app version doesn't know is kept and reads as none.
final String? subject,
}) with Equatable { }) with Equatable {
/// Content with only a [title] and an empty description, as a new /// Content with only a [title] and an empty description, as a new
/// studyset starts. /// studyset starts.
@ -86,6 +93,7 @@ final class const StudysetContent({
publishedAt, publishedAt,
examPlans, examPlans,
exam, exam,
subject,
]; ];
/// This content with a new [title] or [description]. The other fields can /// This content with a new [title] or [description]. The other fields can
@ -107,6 +115,7 @@ final class const StudysetContent({
publishedAt: publishedAt, publishedAt: publishedAt,
examPlans: examPlans, examPlans: examPlans,
exam: exam, exam: exam,
subject: subject,
); );
/// This content studied with [options]; null for the defaults. /// This content studied with [options]; null for the defaults.
@ -127,6 +136,7 @@ final class const StudysetContent({
publishedAt: publishedAt, publishedAt: publishedAt,
examPlans: examPlans, examPlans: examPlans,
exam: exam, exam: exam,
subject: subject,
); );
/// This content citing [bibliography]. /// This content citing [bibliography].
@ -147,6 +157,7 @@ final class const StudysetContent({
publishedAt: publishedAt, publishedAt: publishedAt,
examPlans: examPlans, examPlans: examPlans,
exam: exam, exam: exam,
subject: subject,
); );
/// This content planning for the exams [plans]. /// This content planning for the exams [plans].
@ -166,6 +177,7 @@ final class const StudysetContent({
publishedAt: publishedAt, publishedAt: publishedAt,
examPlans: plans, examPlans: plans,
exam: exam, exam: exam,
subject: subject,
); );
/// This content with the person preparing for [exam]; null for none. /// This content with the person preparing for [exam]; null for none.
@ -185,5 +197,6 @@ final class const StudysetContent({
publishedAt: publishedAt, publishedAt: publishedAt,
examPlans: examPlans, examPlans: examPlans,
exam: exam, exam: exam,
subject: subject,
); );
} }

View file

@ -1,4 +1,5 @@
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/subject.dart';
/// The studysets of [all] that show at the top level, in their order: those /// The studysets of [all] that show at the top level, in their order: those
/// without a parent, those whose parent is gone (deleted, or not synced /// without a parent, those whose parent is gone (deleted, or not synced
@ -161,6 +162,16 @@ String? colorNameOf(String id, List<Studyset> all) {
return null; return null;
} }
/// The subject the studyset [id] is filed under: its top studyset's, which
/// alone has one (decided by the developer, 10 Nov 2026); null where none
/// was chosen or it is one this app version doesn't know.
Subject? subjectOf(String id, List<Studyset> all) {
final top =
ancestorsOf(id, all).firstOrNull ??
all.where((each) => each.id == id).firstOrNull;
return Subject.byId(top?.content.subject);
}
/// The languages of a studyset's cards: of their fronts and of their backs, /// The languages of a studyset's cards: of their fronts and of their backs,
/// as BCP 47 tags; either null when not given. /// as BCP 47 tags; either null when not given.
typedef StudysetLanguages = ({String? front, String? back}); typedef StudysetLanguages = ({String? front, String? back});

View file

@ -0,0 +1,440 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The groups the subject picker lists [Subject]s under.
enum SubjectGroup() {
/// Sciences and health.
sciences,
/// Mathematics and computing.
computing,
/// Society.
society,
/// Languages and arts.
arts,
/// Everyday.
everyday,
/// Other.
other;
/// The group's name in [l10n]'s language.
String nameIn(AppLocalizations l10n) => switch (this) {
SubjectGroup.sciences => l10n.subjectGroupSciences,
SubjectGroup.computing => l10n.subjectGroupComputing,
SubjectGroup.society => l10n.subjectGroupSociety,
SubjectGroup.arts => l10n.subjectGroupArts,
SubjectGroup.everyday => l10n.subjectGroupEveryday,
SubjectGroup.other => l10n.subjectGroupOther,
};
}
/// What a studyset is about, filed under one of a fixed list (decided by
/// the developer, 9 and 10 Nov 2026: Vaia's breadth, languages as one,
/// computing in five): each with its Lucide icon and the colour a studyset
/// takes when it is chosen. A studyset keeps the [id], never renamed; one
/// this app version doesn't know (a newer one's) is kept and read as none.
enum Subject(
/// How studysets keep it.
final String id,
/// Where the picker lists it.
final SubjectGroup group,
/// Its icon.
final IconData icon,
/// The colour a studyset takes when it is chosen, its own to change from
/// then on; none for [other].
final PaletteColor? color,
) {
/// Biology.
biology('biology', SubjectGroup.sciences, LucideIcons.dna, PaletteColor.fern),
/// Chemistry.
chemistry(
'chemistry',
SubjectGroup.sciences,
LucideIcons.flaskConical,
PaletteColor.reef,
),
/// Physics.
physics(
'physics',
SubjectGroup.sciences,
LucideIcons.atom,
PaletteColor.slateblue,
),
/// Astronomy.
astronomy(
'astronomy',
SubjectGroup.sciences,
LucideIcons.telescope,
PaletteColor.enzian,
),
/// Earth sciences.
earthSciences(
'earth-sciences',
SubjectGroup.sciences,
LucideIcons.mountain,
PaletteColor.sahara,
),
/// Environmental science.
environmentalScience(
'environmental-science',
SubjectGroup.sciences,
LucideIcons.leaf,
PaletteColor.forest,
),
/// Medicine.
medicine(
'medicine',
SubjectGroup.sciences,
LucideIcons.stethoscope,
PaletteColor.cerise,
),
/// Nursing.
nursing(
'nursing',
SubjectGroup.sciences,
LucideIcons.heartPulse,
PaletteColor.strawberry,
),
/// Pharmacy.
pharmacy(
'pharmacy',
SubjectGroup.sciences,
LucideIcons.pill,
PaletteColor.pink,
),
/// Nutrition.
nutrition(
'nutrition',
SubjectGroup.sciences,
LucideIcons.apple,
PaletteColor.carrot,
),
/// Psychology.
psychology(
'psychology',
SubjectGroup.sciences,
LucideIcons.brain,
PaletteColor.plum,
),
/// Sports science.
sportsScience(
'sports-science',
SubjectGroup.sciences,
LucideIcons.dumbbell,
PaletteColor.copper,
),
/// Mathematics.
mathematics(
'mathematics',
SubjectGroup.computing,
LucideIcons.sigma,
PaletteColor.cobalt,
),
/// Statistics.
statistics(
'statistics',
SubjectGroup.computing,
LucideIcons.chartColumn,
PaletteColor.ocean,
),
/// Computer science.
computerScience(
'computer-science',
SubjectGroup.computing,
LucideIcons.cpu,
PaletteColor.slateblue,
),
/// Software engineering.
softwareEngineering(
'software-engineering',
SubjectGroup.computing,
LucideIcons.code,
PaletteColor.enzian,
),
/// Data science and AI.
dataScienceAi(
'data-science-ai',
SubjectGroup.computing,
LucideIcons.brainCircuit,
PaletteColor.purple,
),
/// IT and operations.
itOperations(
'it-operations',
SubjectGroup.computing,
LucideIcons.server,
PaletteColor.pine,
),
/// Security and privacy.
securityPrivacy(
'security-privacy',
SubjectGroup.computing,
LucideIcons.shieldCheck,
PaletteColor.forest,
),
/// Engineering.
engineering(
'engineering',
SubjectGroup.computing,
LucideIcons.cog,
PaletteColor.soil,
),
/// Architecture.
architecture(
'architecture',
SubjectGroup.computing,
LucideIcons.building2,
PaletteColor.sahara,
),
/// History.
history(
'history',
SubjectGroup.society,
LucideIcons.landmark,
PaletteColor.soil,
),
/// Geography.
geography(
'geography',
SubjectGroup.society,
LucideIcons.globe,
PaletteColor.pacific,
),
/// Politics.
politics(
'politics',
SubjectGroup.society,
LucideIcons.vote,
PaletteColor.cobalt,
),
/// Law.
law('law', SubjectGroup.society, LucideIcons.scale, PaletteColor.pickle),
/// Economics.
economics(
'economics',
SubjectGroup.society,
LucideIcons.trendingUp,
PaletteColor.olive,
),
/// Business.
business(
'business',
SubjectGroup.society,
LucideIcons.briefcase,
PaletteColor.copper,
),
/// Marketing.
marketing(
'marketing',
SubjectGroup.society,
LucideIcons.megaphone,
PaletteColor.carrot,
),
/// Accounting and finance.
accountingFinance(
'accounting-finance',
SubjectGroup.society,
LucideIcons.calculator,
PaletteColor.pine,
),
/// Sociology.
sociology(
'sociology',
SubjectGroup.society,
LucideIcons.users,
PaletteColor.plum,
),
/// Anthropology.
anthropology(
'anthropology',
SubjectGroup.society,
LucideIcons.footprints,
PaletteColor.sahara,
),
/// Education.
education(
'education',
SubjectGroup.society,
LucideIcons.school,
PaletteColor.ocean,
),
/// Media studies.
mediaStudies(
'media-studies',
SubjectGroup.society,
LucideIcons.tv,
PaletteColor.pink,
),
/// Philosophy.
philosophy(
'philosophy',
SubjectGroup.society,
LucideIcons.lightbulb,
PaletteColor.purple,
),
/// Religious studies.
religiousStudies(
'religious-studies',
SubjectGroup.society,
LucideIcons.bookOpen,
PaletteColor.olive,
),
/// Languages.
languages(
'languages',
SubjectGroup.arts,
LucideIcons.languages,
PaletteColor.cerise,
),
/// Literature.
literature(
'literature',
SubjectGroup.arts,
LucideIcons.bookMarked,
PaletteColor.strawberry,
),
/// Linguistics.
linguistics(
'linguistics',
SubjectGroup.arts,
LucideIcons.speech,
PaletteColor.enzian,
),
/// Art and design.
artDesign(
'art-design',
SubjectGroup.arts,
LucideIcons.palette,
PaletteColor.pink,
),
/// Music.
music('music', SubjectGroup.arts, LucideIcons.music, PaletteColor.plum),
/// Theatre and film.
theatreFilm(
'theatre-film',
SubjectGroup.arts,
LucideIcons.clapperboard,
PaletteColor.cerise,
),
/// Cooking and hospitality.
cookingHospitality(
'cooking-hospitality',
SubjectGroup.everyday,
LucideIcons.chefHat,
PaletteColor.carrot,
),
/// Driving and licences.
drivingLicences(
'driving-licences',
SubjectGroup.everyday,
LucideIcons.car,
PaletteColor.soil,
),
/// Other / not sure.
other('other', SubjectGroup.other, LucideIcons.circleQuestionMark, null);
/// The subject kept as [id]; null for none or one this app doesn't know.
static Subject? byId(String? id) => _byId[id];
static final _byId = {for (final subject in values) subject.id: subject};
/// Its name in [l10n]'s language.
String nameIn(AppLocalizations l10n) => switch (this) {
Subject.biology => l10n.subjectBiology,
Subject.chemistry => l10n.subjectChemistry,
Subject.physics => l10n.subjectPhysics,
Subject.astronomy => l10n.subjectAstronomy,
Subject.earthSciences => l10n.subjectEarthSciences,
Subject.environmentalScience => l10n.subjectEnvironmentalScience,
Subject.medicine => l10n.subjectMedicine,
Subject.nursing => l10n.subjectNursing,
Subject.pharmacy => l10n.subjectPharmacy,
Subject.nutrition => l10n.subjectNutrition,
Subject.psychology => l10n.subjectPsychology,
Subject.sportsScience => l10n.subjectSportsScience,
Subject.mathematics => l10n.subjectMathematics,
Subject.statistics => l10n.subjectStatistics,
Subject.computerScience => l10n.subjectComputerScience,
Subject.softwareEngineering => l10n.subjectSoftwareEngineering,
Subject.dataScienceAi => l10n.subjectDataScienceAi,
Subject.itOperations => l10n.subjectItOperations,
Subject.securityPrivacy => l10n.subjectSecurityPrivacy,
Subject.engineering => l10n.subjectEngineering,
Subject.architecture => l10n.subjectArchitecture,
Subject.history => l10n.subjectHistory,
Subject.geography => l10n.subjectGeography,
Subject.politics => l10n.subjectPolitics,
Subject.law => l10n.subjectLaw,
Subject.economics => l10n.subjectEconomics,
Subject.business => l10n.subjectBusiness,
Subject.marketing => l10n.subjectMarketing,
Subject.accountingFinance => l10n.subjectAccountingFinance,
Subject.sociology => l10n.subjectSociology,
Subject.anthropology => l10n.subjectAnthropology,
Subject.education => l10n.subjectEducation,
Subject.mediaStudies => l10n.subjectMediaStudies,
Subject.philosophy => l10n.subjectPhilosophy,
Subject.religiousStudies => l10n.subjectReligiousStudies,
Subject.languages => l10n.subjectLanguages,
Subject.literature => l10n.subjectLiterature,
Subject.linguistics => l10n.subjectLinguistics,
Subject.artDesign => l10n.subjectArtDesign,
Subject.music => l10n.subjectMusic,
Subject.theatreFilm => l10n.subjectTheatreFilm,
Subject.cookingHospitality => l10n.subjectCookingHospitality,
Subject.drivingLicences => l10n.subjectDrivingLicences,
Subject.other => l10n.subjectOther,
};
}

View file

@ -7,7 +7,9 @@ import 'package:halkyon_learn_flutter/src/core/l10n/content_languages.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/license.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/license.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.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/studyset_tree.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/subject.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/license_chooser_sheet.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/widget/license_chooser_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/subject_picker.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -113,6 +115,10 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
null => widget.insideAnother ? null : _defaultColor.name, null => widget.insideAnother ? null : _defaultColor.name,
}; };
/// The ID of the subject it is filed under, on a top studyset alone;
/// one only a newer app version knows is kept until another is picked.
late String? _subject = widget.initial?.subject;
/// Whether the studyset gives languages of its own: off, it takes those /// Whether the studyset gives languages of its own: off, it takes those
/// of the studyset it is in, or has none at the top level. /// of the studyset it is in, or has none at the top level.
late var _setLanguages = late var _setLanguages =
@ -222,6 +228,7 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
publishedAt: initial?.publishedAt, publishedAt: initial?.publishedAt,
examPlans: initial?.examPlans ?? const [], examPlans: initial?.examPlans ?? const [],
exam: initial?.exam, exam: initial?.exam,
subject: _subject,
); );
} }
@ -233,6 +240,7 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
_description != RichContent.empty() || _description != RichContent.empty() ||
_colorName != (widget.insideAnother ? null : _defaultColor.name) || _colorName != (widget.insideAnother ? null : _defaultColor.name) ||
_licenseNow != _licenseBefore || _licenseNow != _licenseBefore ||
_subject != null ||
(_setLanguages && (_frontLanguage ?? _backLanguage) != null), (_setLanguages && (_frontLanguage ?? _backLanguage) != null),
}; };
@ -243,6 +251,19 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
PaletteColor.byName(widget.inheritedColorName) ?? PaletteColor.byName(widget.inheritedColorName) ??
_defaultColor; _defaultColor;
/// Asks for the subject; one picked gives the studyset its colour, the
/// studyset's to change from then on (decided by the developer, 9 Nov
/// 2026).
Future<void> _pickSubject() async {
if (await pickSubject(context, selected: Subject.byId(_subject))
case final subject?) {
setState(() {
_subject = subject.id;
_colorName = subject.color?.name ?? _colorName;
});
}
}
Future<void> _editDescription() async { Future<void> _editDescription() async {
if (await editRichText( if (await editRichText(
context, context,
@ -330,6 +351,19 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
description: _description, description: _description,
onTap: _editDescription, onTap: _editDescription,
), ),
// A sub-studyset's subject is its top studyset's.
if (!widget.insideAnother) ...[
const SizedBox(height: 24),
Text(
l10n.studysetSheetSubject,
style: theme.textTheme.labelLarge,
),
const SizedBox(height: 8),
_SubjectField(
subject: Subject.byId(_subject),
onTap: _pickSubject,
),
],
const SizedBox(height: 24), const SizedBox(height: 24),
Text(l10n.studysetSheetColor, style: theme.textTheme.labelLarge), Text(l10n.studysetSheetColor, style: theme.textTheme.labelLarge),
const SizedBox(height: 8), const SizedBox(height: 8),
@ -552,6 +586,37 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
/// The licence, typed: any text, suggesting the [knownLicenses] that hold /// The licence, typed: any text, suggesting the [knownLicenses] that hold
/// what is typed so far. /// what is typed so far.
/// The subject chosen, or an invitation to choose one, opening the picker.
class const _SubjectField({
required final Subject? subject,
required final VoidCallback onTap,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return ListTile(
shape: RoundedRectangleBorder(
side: BorderSide(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(4),
),
contentPadding: const EdgeInsetsDirectional.only(start: 12, end: 8),
leading: switch (subject) {
final subject? => SubjectBadge(subject),
null => const Icon(LucideIcons.shapes),
},
title: Text(
subject?.nameIn(l10n) ?? l10n.studysetSheetSubjectNone,
style: subject == null
? TextStyle(color: theme.colorScheme.onSurfaceVariant)
: null,
),
trailing: const Icon(LucideIcons.chevronDown),
onTap: onTap,
);
}
}
class const _LicenseField({ class const _LicenseField({
required final TextEditingController controller, required final TextEditingController controller,
required final FocusNode focusNode, required final FocusNode focusNode,

View file

@ -0,0 +1,81 @@
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:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Asks for a studyset's subject among every [Subject], under their groups,
/// [selected] ticked; completes with the one tapped, or null when the sheet
/// is closed.
Future<Subject?> pickSubject(BuildContext context, {Subject? selected}) =>
showAdaptiveSheet<Subject>(
context,
builder: (context) => _SubjectPicker(selected: selected),
);
class const _SubjectPicker({required final Subject? selected})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return SheetScaffold(
title: l10n.subjectPickerTitle,
heightFactor: 0.85,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final group in SubjectGroup.values) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
group.nameIn(l10n),
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
for (final subject in Subject.values)
if (subject.group == group)
ListTile(
leading: SubjectBadge(subject),
title: Text(subject.nameIn(l10n)),
trailing: subject == selected
? const Icon(LucideIcons.check)
: null,
selected: subject == selected,
onTap: () => Navigator.of(context).pop(subject),
),
],
],
),
);
}
}
/// A subject's icon on its colour, as a round badge; on the surface's own
/// tone for one without a colour.
class const SubjectBadge(
final Subject subject, {
final double size = 32,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final color = subject.color;
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: color?.background ?? scheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(
subject.icon,
size: size * 0.55,
color: color?.foreground ?? scheme.onSurfaceVariant,
),
);
}
}

View file

@ -46,11 +46,27 @@ void main() {
licenseYear: 2006, licenseYear: 2006,
frontLanguage: 'ja', frontLanguage: 'ja',
backLanguage: 'en', backLanguage: 'en',
subject: 'languages',
); );
expect(decodeStudysetPayload(encodeStudysetPayload(content)), content); expect(decodeStudysetPayload(encodeStudysetPayload(content)), content);
}); });
test('keeps a subject this app version does not know, as a newer one '
'may add subjects', () {
final content = StudysetContent(
title: 'Origami',
description: RichContent.empty(),
subject: 'paper-folding',
);
expect(json(encodeStudysetPayload(content))['subject'], 'paper-folding');
expect(
decodeStudysetPayload(encodeStudysetPayload(content)).subject,
'paper-folding',
);
});
test('leaves out what is not given', () { test('leaves out what is not given', () {
final payload = encodeStudysetPayload( final payload = encodeStudysetPayload(
StudysetContent(title: 'Japanese', description: RichContent.empty()), StudysetContent(title: 'Japanese', description: RichContent.empty()),
@ -59,7 +75,7 @@ void main() {
expect(json(payload).keys, {'schemaVersion', 'title', 'description'}); expect(json(payload).keys, {'schemaVersion', 'title', 'description'});
}); });
test('names its schema version: the seventh, which exam plans need', () { test('names its schema version: the eighth, which subjects need', () {
final payload = encodeStudysetPayload( final payload = encodeStudysetPayload(
StudysetContent( StudysetContent(
title: 'Japanese', title: 'Japanese',
@ -68,17 +84,17 @@ void main() {
), ),
); );
expect(studysetSchemaVersion, 7); expect(studysetSchemaVersion, 8);
expect(json(payload)['schemaVersion'], studysetSchemaVersion); expect(json(payload)['schemaVersion'], studysetSchemaVersion);
}); });
test('without study options, a bibliography or exam plans writes the ' test('without study options, a bibliography, exam plans or a subject '
'seventh schema all the same', () { 'writes the eighth schema all the same', () {
final payload = encodeStudysetPayload( final payload = encodeStudysetPayload(
StudysetContent(title: 'Japanese', description: RichContent.empty()), StudysetContent(title: 'Japanese', description: RichContent.empty()),
); );
expect(json(payload)['schemaVersion'], 7); expect(json(payload)['schemaVersion'], 8);
expect(json(payload).keys, isNot(contains('study'))); expect(json(payload).keys, isNot(contains('study')));
expect(json(payload).keys, isNot(contains('bibliography'))); expect(json(payload).keys, isNot(contains('bibliography')));
expect(json(payload).keys, isNot(contains('examPlans'))); expect(json(payload).keys, isNot(contains('examPlans')));

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.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.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/studyset_tree.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/subject.dart';
Studyset studyset( Studyset studyset(
String id, { String id, {
@ -196,6 +197,32 @@ void main() {
expect(colorNameOf('german', all), isNull); 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 ' test('a studyset without languages of its own takes those of the nearest '
'studyset above it that gives any, both together', () { 'studyset above it that gives any, both together', () {
Studyset speaking( 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');
});
}

View file

@ -14,6 +14,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/deleted_studyset_card.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/widget/deleted_studyset_card.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_card.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_card.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/subject_picker.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -74,6 +75,7 @@ void main() {
DateTime? archivedAt, DateTime? archivedAt,
DateTime? starredAt, DateTime? starredAt,
String? publishedAt, String? publishedAt,
String? subject,
bool private = false, bool private = false,
}) => gateway.receiveRemoteChange( }) => gateway.receiveRemoteChange(
id, id,
@ -86,6 +88,7 @@ void main() {
? null ? null
: const PublishedRef(publication: 'p', item: 'i', version: 1), : const PublishedRef(publication: 'p', item: 'i', version: 1),
publishedAt: publishedAt, publishedAt: publishedAt,
subject: subject,
), ),
), ),
archivedAt: archivedAt, archivedAt: archivedAt,
@ -267,6 +270,109 @@ void main() {
); );
}); });
group("a studyset's subject (the developer's decisions of 9 and 10 Nov "
'2026)', () {
Future<void> pickSubject(WidgetTester tester, String name) async {
await tester.tap(find.text('Choose a subject'));
await tester.pumpAndSettle();
final row = find.widgetWithText(ListTile, name);
await tester.scrollUntilVisible(
row,
200,
scrollable: find
.descendant(
of: find.byType(SheetScaffold).last,
matching: find.byType(Scrollable),
)
.first,
);
await tester.pumpAndSettle();
await tester.tap(row);
await tester.pumpAndSettle();
}
/// The subject the sheet's field shows.
Finder subjectShown(String name) => find.descendant(
of: find.ancestor(
of: find.byType(SubjectBadge),
matching: find.byType(ListTile),
),
matching: find.text(name),
);
testWidgets('is picked from the subjects in their groups, and gives the '
'studyset its colour', (tester) async {
final semantics = tester.ensureSemantics();
await showLibrary(tester);
await tester.tap(find.text('Create studyset'));
await tester.pumpAndSettle();
await tester.enterText(find.widgetWithText(TextField, 'Title'), 'Kanji');
await tester.tap(find.text('Choose a subject'));
await tester.pumpAndSettle();
expect(find.text('Sciences and health'), findsOneWidget);
await tester.tap(find.byTooltip('Close').last);
await tester.pumpAndSettle();
await pickSubject(tester, 'Languages');
expect(subjectShown('Languages'), findsOneWidget);
expect(find.bySemanticsLabel('Color: Cerise'), findsOneWidget);
await tester.tap(find.text('Create'));
await tester.pumpAndSettle();
final content = decodeStudysetPayload(gateway.writes.single.$2);
expect(content.subject, 'languages');
expect(content.colorName, 'cerise');
semantics.dispose();
});
testWidgets("its colour stays the studyset's to change", (tester) async {
final semantics = tester.ensureSemantics();
await showLibrary(tester);
await tester.tap(find.text('Create studyset'));
await tester.pumpAndSettle();
await tester.enterText(find.widgetWithText(TextField, 'Title'), 'Rust');
await pickSubject(tester, 'Software engineering');
await tester.tap(find.bySemanticsLabel('Color: Enzian'));
await tester.pumpAndSettle();
await tester.tap(find.bySemanticsLabel('Fern'));
await tester.pumpAndSettle();
await tester.tap(find.text('Create'));
await tester.pumpAndSettle();
final content = decodeStudysetPayload(gateway.writes.single.$2);
expect(content.subject, 'software-engineering');
expect(content.colorName, 'fern');
semantics.dispose();
});
testWidgets('editing shows the one chosen, and keeps one this app '
'version does not know', (tester) async {
stored('origami', 'Origami', subject: 'paper-folding');
stored('japanese', 'Japanese', subject: 'languages');
await showLibrary(tester);
await choose(tester, 'Japanese', 'Edit');
expect(subjectShown('Languages'), findsOneWidget);
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
await choose(tester, 'Origami', 'Edit');
expect(find.text('Choose a subject'), findsOneWidget);
await tester.enterText(
find.widgetWithText(TextField, 'Origami'),
'Paper',
);
await tester.pump();
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(
decodeStudysetPayload(gateway.writes.last.$2).subject,
'paper-folding',
);
});
});
testWidgets('creating waits for a title', (tester) async { testWidgets('creating waits for a title', (tester) async {
await showLibrary(tester); await showLibrary(tester);
await tester.tap(find.text('Create studyset')); await tester.tap(find.text('Create studyset'));

View file

@ -396,6 +396,11 @@ void main() {
tester.getRect(find.text('Inherit')).left, tester.getRect(find.text('Inherit')).left,
greaterThan(tester.getRect(find.byType(PaletteColorPicker)).right), greaterThan(tester.getRect(find.byType(PaletteColorPicker)).right),
); );
expect(
find.text('Choose a subject'),
findsNothing,
reason: "a sub-studyset's subject is its top studyset's",
);
await tester.enterText(find.widgetWithText(TextField, 'Title'), 'Kana'); await tester.enterText(find.widgetWithText(TextField, 'Title'), 'Kana');
await tester.pump(); await tester.pump();
await tester.tap(find.text('Create')); await tester.tap(find.text('Create'));

View file

@ -0,0 +1,98 @@
# ADR 0041: Learn's subjects: one per studyset, from a fixed list
- **Status:** decided 9 and 10 Nov 2026 by the developer: D1 to D4 and D6 are their own
choices, D5 and D7 to D9 the agent's recommendations, which they confirmed. D1 to D7
are built; D8 (the library) is next, and D10 later.
- **Builds on:** ADR 0002 (payloads and their schemas), ADR 0005 (publishing), ADR 0031
(the palette's twenty colours).
## Context
A library of a hundred studysets, most of them computing books and language courses,
shows them by title alone. Vaia files every studyset under a subject from a fixed
list, shown on its card with a stripe in the subject's colour, and lets the library be
narrowed to one.
## Decisions
- **D1.** One subject per studyset, from a fixed list of about Vaia's breadth, with
*Other / not sure* last.
- **D2.** Each subject has a Lucide icon and a default colour from the palette. Choosing
one gives the studyset that colour, which stays the studyset's to change.
- **D3.** Languages are one subject, *Languages*. The language itself comes from the
studyset's front language, which every course already gives.
- **D4.** Computing is five subjects: *Computer science*, *Software engineering*, *Data
science and AI*, *IT and operations*, *Security and privacy*. Most of the library is
computing, and a single subject would narrow nothing.
- **D5.** The subject sits on the top studyset alone, which its sub-studysets show as
theirs (`subjectOf`); their sheet offers none. It is kept in the studyset's sealed
payload as `subject`, a subject's ID (schema 8), and so goes into archives and is
published with the studyset, for Explore to filter by later.
- **D6.** The list is the agent's draft of 10 Nov 2026, taken as it is (below).
- **D7.** IDs are never renamed. An ID this app version doesn't know (a newer one's) is
kept as it is and reads as no subject until another is picked.
- **D8.** The library, as Vaia's: a dropdown of the subjects in use, cards naming the
subject under the title with a stripe in its colour, sections by recency (the last 7
days, the last month, older), the archive last.
- **D9.** Existing studysets: the agent writes a subject into each of the developer's 72
archives not yet imported; those already in the library get one by hand.
- **D10.** Explore filters by subject later; its releases don't keep the subject in a
column yet (it is in each release's repository).
## The list (D6)
ID, English and German name, Lucide icon, default colour (`Subject`, `subject.dart`).
| Group | ID | English | German | Icon | Colour |
|---|---|---|---|---|---|
| Sciences and health | `biology` | Biology | Biologie | dna | fern |
| | `chemistry` | Chemistry | Chemie | flask-conical | reef |
| | `physics` | Physics | Physik | atom | slateblue |
| | `astronomy` | Astronomy | Astronomie | telescope | enzian |
| | `earth-sciences` | Earth sciences | Geowissenschaften | mountain | sahara |
| | `environmental-science` | Environmental science | Umweltwissenschaften | leaf | forest |
| | `medicine` | Medicine | Medizin | stethoscope | cerise |
| | `nursing` | Nursing | Pflege | heart-pulse | strawberry |
| | `pharmacy` | Pharmacy | Pharmazie | pill | pink |
| | `nutrition` | Nutrition | Ernährung | apple | carrot |
| | `psychology` | Psychology | Psychologie | brain | plum |
| | `sports-science` | Sports science | Sportwissenschaft | dumbbell | copper |
| Mathematics and computing | `mathematics` | Mathematics | Mathematik | sigma | cobalt |
| | `statistics` | Statistics | Statistik | chart-column | ocean |
| | `computer-science` | Computer science | Informatik | cpu | slateblue |
| | `software-engineering` | Software engineering | Softwareentwicklung | code | enzian |
| | `data-science-ai` | Data science and AI | Data Science und KI | brain-circuit | purple |
| | `it-operations` | IT and operations | IT und Betrieb | server | pine |
| | `security-privacy` | Security and privacy | Sicherheit und Datenschutz | shield-check | forest |
| | `engineering` | Engineering | Ingenieurwesen | cog | soil |
| | `architecture` | Architecture | Architektur | building-2 | sahara |
| Society | `history` | History | Geschichte | landmark | soil |
| | `geography` | Geography | Geografie | globe | pacific |
| | `politics` | Politics | Politik | vote | cobalt |
| | `law` | Law | Recht | scale | pickle |
| | `economics` | Economics | Volkswirtschaft | trending-up | olive |
| | `business` | Business | Betriebswirtschaft | briefcase | copper |
| | `marketing` | Marketing | Marketing | megaphone | carrot |
| | `accounting-finance` | Accounting and finance | Rechnungswesen und Finanzen | calculator | pine |
| | `sociology` | Sociology | Soziologie | users | plum |
| | `anthropology` | Anthropology | Anthropologie | footprints | sahara |
| | `education` | Education | Pädagogik | school | ocean |
| | `media-studies` | Media studies | Medienwissenschaft | tv | pink |
| | `philosophy` | Philosophy | Philosophie | lightbulb | purple |
| | `religious-studies` | Religious studies | Religionswissenschaft | book-open | olive |
| Languages and arts | `languages` | Languages | Sprachen | languages | cerise |
| | `literature` | Literature | Literatur | book-marked | strawberry |
| | `linguistics` | Linguistics | Linguistik | speech | enzian |
| | `art-design` | Art and design | Kunst und Design | palette | pink |
| | `music` | Music | Musik | music | plum |
| | `theatre-film` | Theatre and film | Theater und Film | clapperboard | cerise |
| Everyday | `cooking-hospitality` | Cooking and hospitality | Kochen und Gastronomie | chef-hat | carrot |
| | `driving-licences` | Driving and licences | Führerschein und Lizenzen | car | soil |
| Other | `other` | Other / not sure | Sonstiges / unsicher | circle-question-mark | none |
## Consequences
- A studyset saved by this version is in schema 8, which older versions refuse to read,
as every schema before (ADR 0002): an archive with subjects imports from this version
on.
- Adding a subject is cheap; renaming an ID isn't (D7), so names change, IDs don't.