fix(app): a path import that fails says why in the app's language

The importer said what was wrong with a file in English sentences built
in the data layer, the last English left in a German app. It now names
the problem, the step it is in and what the problem names (a field, a
type, a number), and the studyset screen puts it into words: "Step 2:
unknown "type": quiz." or "Schritt 2: unbekannter „type“: quiz.". The
format's own field names stay as they are in the file.

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-07 03:01:12 +02:00
commit 1921043925
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
12 changed files with 670 additions and 68 deletions

View file

@ -99,7 +99,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone. - **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star, which needs the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, and the sheets below). - **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star, which needs the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, and the sheets below).
- **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none. - **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none.
- **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files. - **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files.
## Testing ## Testing

View file

@ -832,5 +832,32 @@
"homeWidgetPomodoro": "Pomodoro-Timer", "homeWidgetPomodoro": "Pomodoro-Timer",
"pomodoroAlerts": "Pomodoro-Meldungen", "pomodoroAlerts": "Pomodoro-Meldungen",
"pomodoroUntil": "Bis {time}", "pomodoroUntil": "Bis {time}",
"pomodoroLeft": "Noch {left}" "pomodoroLeft": "Noch {left}",
"pathImportAtStep": "Schritt {step}: {problem}",
"pathImportNotJson": "Das ist kein JSON: {detail}",
"pathImportNoPath": "ein „path“-Objekt fehlt.",
"pathImportNoTitle": "der Pfad hat keinen „title“.",
"pathImportDescriptionNotText": "„description“ des Pfads ist kein Text.",
"pathImportNotAnObject": "kein Objekt.",
"pathImportUnknownStepType": "unbekannter „type“: {detail}.",
"pathImportRightOptionMissing": "die richtige Option {detail} fehlt unter den Optionen.",
"pathImportRightOptionsMissing": "eine genannte richtige Option fehlt unter den Optionen.",
"pathImportNoPairing": "kein „correct_pairing“.",
"pathImportPairingTargetMissing": "Elemente werden einem fehlenden Ziel zugeordnet: {detail}.",
"pathImportPairingIncomplete": "jedes Element braucht genau ein Ziel.",
"pathImportNoPassage": "keine „passage“.",
"pathImportBlanksMismatch": "für jedes ___ in der Passage braucht es ein Wort in „blanks“.",
"pathImportBlockWithoutType": "ein Block ohne „type“.",
"pathImportMalformedLevel": "eine Überschrift mit fehlerhafter Ebene.",
"pathImportMalformedListItem": "ein fehlerhafter Listenpunkt.",
"pathImportMalformedMath": "ein fehlerhafter Formelblock.",
"pathImportMalformedCode": "ein fehlerhafter Codeblock.",
"pathImportUnknownBlock": "ein unbekannter Block: {detail}.",
"pathImportTextlessBlock": "ein „{detail}“ ohne „text“ oder „runs“.",
"pathImportMalformedRun": "ein fehlerhafter Textabschnitt.",
"pathImportListExpected": "eine Liste wurde erwartet.",
"pathImportNotText": "„{detail}“ muss Text sein.",
"pathImportTooFew": "es braucht mindestens zwei „{detail}“.",
"pathImportMalformedNumber": "eine fehlerhafte Zahl.",
"pathImportTitleNotText": "ein Titel muss Text sein."
} }

View file

@ -997,5 +997,41 @@
"pomodoroUntil": "Until {time}", "pomodoroUntil": "Until {time}",
"@pomodoroUntil": {"placeholders": {"time": {"type": "String"}}}, "@pomodoroUntil": {"placeholders": {"time": {"type": "String"}}},
"pomodoroLeft": "{left} left", "pomodoroLeft": "{left} left",
"@pomodoroLeft": {"placeholders": {"left": {"type": "String"}}} "@pomodoroLeft": {"placeholders": {"left": {"type": "String"}}},
"pathImportAtStep": "Step {step}: {problem}",
"@pathImportAtStep": {"placeholders": {"step": {"type": "int"}, "problem": {"type": "String"}}},
"pathImportNotJson": "This is no JSON: {detail}",
"@pathImportNotJson": {"placeholders": {"detail": {"type": "String"}}},
"pathImportNoPath": "a \"path\" object is missing.",
"pathImportNoTitle": "the path has no \"title\".",
"pathImportDescriptionNotText": "the path's \"description\" is no text.",
"pathImportNotAnObject": "no object.",
"pathImportUnknownStepType": "unknown \"type\": {detail}.",
"@pathImportUnknownStepType": {"placeholders": {"detail": {"type": "String"}}},
"pathImportRightOptionMissing": "its right option {detail} is not among its options.",
"@pathImportRightOptionMissing": {"placeholders": {"detail": {"type": "String"}}},
"pathImportRightOptionsMissing": "it names a right option that is not among its options.",
"pathImportNoPairing": "no \"correct_pairing\".",
"pathImportPairingTargetMissing": "it pairs items with a target that isn't there: {detail}.",
"@pathImportPairingTargetMissing": {"placeholders": {"detail": {"type": "String"}}},
"pathImportPairingIncomplete": "every item must be paired with one target.",
"pathImportNoPassage": "no \"passage\".",
"pathImportBlanksMismatch": "it needs one word in \"blanks\" for each ___ in its passage.",
"pathImportBlockWithoutType": "a block without \"type\".",
"pathImportMalformedLevel": "a heading with a malformed level.",
"pathImportMalformedListItem": "a malformed list item.",
"pathImportMalformedMath": "a malformed math block.",
"pathImportMalformedCode": "a malformed code block.",
"pathImportUnknownBlock": "an unknown block: {detail}.",
"@pathImportUnknownBlock": {"placeholders": {"detail": {"type": "String"}}},
"pathImportTextlessBlock": "a {detail} without \"text\" or \"runs\".",
"@pathImportTextlessBlock": {"placeholders": {"detail": {"type": "String"}}},
"pathImportMalformedRun": "a malformed run of text.",
"pathImportListExpected": "a list was expected.",
"pathImportNotText": "\"{detail}\" must be text.",
"@pathImportNotText": {"placeholders": {"detail": {"type": "String"}}},
"pathImportTooFew": "it needs at least two \"{detail}\".",
"@pathImportTooFew": {"placeholders": {"detail": {"type": "String"}}},
"pathImportMalformedNumber": "a malformed number.",
"pathImportTitleNotText": "a title must be text."
} }

View file

@ -4988,6 +4988,168 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'{left} left'** /// **'{left} left'**
String pomodoroLeft(String left); String pomodoroLeft(String left);
/// No description provided for @pathImportAtStep.
///
/// In en, this message translates to:
/// **'Step {step}: {problem}'**
String pathImportAtStep(int step, String problem);
/// No description provided for @pathImportNotJson.
///
/// In en, this message translates to:
/// **'This is no JSON: {detail}'**
String pathImportNotJson(String detail);
/// No description provided for @pathImportNoPath.
///
/// In en, this message translates to:
/// **'a \"path\" object is missing.'**
String get pathImportNoPath;
/// No description provided for @pathImportNoTitle.
///
/// In en, this message translates to:
/// **'the path has no \"title\".'**
String get pathImportNoTitle;
/// No description provided for @pathImportDescriptionNotText.
///
/// In en, this message translates to:
/// **'the path\'s \"description\" is no text.'**
String get pathImportDescriptionNotText;
/// No description provided for @pathImportNotAnObject.
///
/// In en, this message translates to:
/// **'no object.'**
String get pathImportNotAnObject;
/// No description provided for @pathImportUnknownStepType.
///
/// In en, this message translates to:
/// **'unknown \"type\": {detail}.'**
String pathImportUnknownStepType(String detail);
/// No description provided for @pathImportRightOptionMissing.
///
/// In en, this message translates to:
/// **'its right option {detail} is not among its options.'**
String pathImportRightOptionMissing(String detail);
/// No description provided for @pathImportRightOptionsMissing.
///
/// In en, this message translates to:
/// **'it names a right option that is not among its options.'**
String get pathImportRightOptionsMissing;
/// No description provided for @pathImportNoPairing.
///
/// In en, this message translates to:
/// **'no \"correct_pairing\".'**
String get pathImportNoPairing;
/// No description provided for @pathImportPairingTargetMissing.
///
/// In en, this message translates to:
/// **'it pairs items with a target that isn\'t there: {detail}.'**
String pathImportPairingTargetMissing(String detail);
/// No description provided for @pathImportPairingIncomplete.
///
/// In en, this message translates to:
/// **'every item must be paired with one target.'**
String get pathImportPairingIncomplete;
/// No description provided for @pathImportNoPassage.
///
/// In en, this message translates to:
/// **'no \"passage\".'**
String get pathImportNoPassage;
/// No description provided for @pathImportBlanksMismatch.
///
/// In en, this message translates to:
/// **'it needs one word in \"blanks\" for each ___ in its passage.'**
String get pathImportBlanksMismatch;
/// No description provided for @pathImportBlockWithoutType.
///
/// In en, this message translates to:
/// **'a block without \"type\".'**
String get pathImportBlockWithoutType;
/// No description provided for @pathImportMalformedLevel.
///
/// In en, this message translates to:
/// **'a heading with a malformed level.'**
String get pathImportMalformedLevel;
/// No description provided for @pathImportMalformedListItem.
///
/// In en, this message translates to:
/// **'a malformed list item.'**
String get pathImportMalformedListItem;
/// No description provided for @pathImportMalformedMath.
///
/// In en, this message translates to:
/// **'a malformed math block.'**
String get pathImportMalformedMath;
/// No description provided for @pathImportMalformedCode.
///
/// In en, this message translates to:
/// **'a malformed code block.'**
String get pathImportMalformedCode;
/// No description provided for @pathImportUnknownBlock.
///
/// In en, this message translates to:
/// **'an unknown block: {detail}.'**
String pathImportUnknownBlock(String detail);
/// No description provided for @pathImportTextlessBlock.
///
/// In en, this message translates to:
/// **'a {detail} without \"text\" or \"runs\".'**
String pathImportTextlessBlock(String detail);
/// No description provided for @pathImportMalformedRun.
///
/// In en, this message translates to:
/// **'a malformed run of text.'**
String get pathImportMalformedRun;
/// No description provided for @pathImportListExpected.
///
/// In en, this message translates to:
/// **'a list was expected.'**
String get pathImportListExpected;
/// No description provided for @pathImportNotText.
///
/// In en, this message translates to:
/// **'\"{detail}\" must be text.'**
String pathImportNotText(String detail);
/// No description provided for @pathImportTooFew.
///
/// In en, this message translates to:
/// **'it needs at least two \"{detail}\".'**
String pathImportTooFew(String detail);
/// No description provided for @pathImportMalformedNumber.
///
/// In en, this message translates to:
/// **'a malformed number.'**
String get pathImportMalformedNumber;
/// No description provided for @pathImportTitleNotText.
///
/// In en, this message translates to:
/// **'a title must be text.'**
String get pathImportTitleNotText;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -3017,4 +3017,108 @@ class AppLocalizationsDe extends AppLocalizations {
String pomodoroLeft(String left) { String pomodoroLeft(String left) {
return 'Noch $left'; return 'Noch $left';
} }
@override
String pathImportAtStep(int step, String problem) {
return 'Schritt $step: $problem';
}
@override
String pathImportNotJson(String detail) {
return 'Das ist kein JSON: $detail';
}
@override
String get pathImportNoPath => 'ein „path“-Objekt fehlt.';
@override
String get pathImportNoTitle => 'der Pfad hat keinen „title“.';
@override
String get pathImportDescriptionNotText =>
'„description“ des Pfads ist kein Text.';
@override
String get pathImportNotAnObject => 'kein Objekt.';
@override
String pathImportUnknownStepType(String detail) {
return 'unbekannter „type“: $detail.';
}
@override
String pathImportRightOptionMissing(String detail) {
return 'die richtige Option $detail fehlt unter den Optionen.';
}
@override
String get pathImportRightOptionsMissing =>
'eine genannte richtige Option fehlt unter den Optionen.';
@override
String get pathImportNoPairing => 'kein „correct_pairing“.';
@override
String pathImportPairingTargetMissing(String detail) {
return 'Elemente werden einem fehlenden Ziel zugeordnet: $detail.';
}
@override
String get pathImportPairingIncomplete =>
'jedes Element braucht genau ein Ziel.';
@override
String get pathImportNoPassage => 'keine „passage“.';
@override
String get pathImportBlanksMismatch =>
'für jedes ___ in der Passage braucht es ein Wort in „blanks“.';
@override
String get pathImportBlockWithoutType => 'ein Block ohne „type“.';
@override
String get pathImportMalformedLevel =>
'eine Überschrift mit fehlerhafter Ebene.';
@override
String get pathImportMalformedListItem => 'ein fehlerhafter Listenpunkt.';
@override
String get pathImportMalformedMath => 'ein fehlerhafter Formelblock.';
@override
String get pathImportMalformedCode => 'ein fehlerhafter Codeblock.';
@override
String pathImportUnknownBlock(String detail) {
return 'ein unbekannter Block: $detail.';
}
@override
String pathImportTextlessBlock(String detail) {
return 'ein „$detail“ ohne „text“ oder „runs“.';
}
@override
String get pathImportMalformedRun => 'ein fehlerhafter Textabschnitt.';
@override
String get pathImportListExpected => 'eine Liste wurde erwartet.';
@override
String pathImportNotText(String detail) {
return '„$detail“ muss Text sein.';
}
@override
String pathImportTooFew(String detail) {
return 'es braucht mindestens zwei „$detail“.';
}
@override
String get pathImportMalformedNumber => 'eine fehlerhafte Zahl.';
@override
String get pathImportTitleNotText => 'ein Titel muss Text sein.';
} }

View file

@ -2987,6 +2987,109 @@ class AppLocalizationsEn extends AppLocalizations {
String pomodoroLeft(String left) { String pomodoroLeft(String left) {
return '$left left'; return '$left left';
} }
@override
String pathImportAtStep(int step, String problem) {
return 'Step $step: $problem';
}
@override
String pathImportNotJson(String detail) {
return 'This is no JSON: $detail';
}
@override
String get pathImportNoPath => 'a \"path\" object is missing.';
@override
String get pathImportNoTitle => 'the path has no \"title\".';
@override
String get pathImportDescriptionNotText =>
'the path\'s \"description\" is no text.';
@override
String get pathImportNotAnObject => 'no object.';
@override
String pathImportUnknownStepType(String detail) {
return 'unknown \"type\": $detail.';
}
@override
String pathImportRightOptionMissing(String detail) {
return 'its right option $detail is not among its options.';
}
@override
String get pathImportRightOptionsMissing =>
'it names a right option that is not among its options.';
@override
String get pathImportNoPairing => 'no \"correct_pairing\".';
@override
String pathImportPairingTargetMissing(String detail) {
return 'it pairs items with a target that isn\'t there: $detail.';
}
@override
String get pathImportPairingIncomplete =>
'every item must be paired with one target.';
@override
String get pathImportNoPassage => 'no \"passage\".';
@override
String get pathImportBlanksMismatch =>
'it needs one word in \"blanks\" for each ___ in its passage.';
@override
String get pathImportBlockWithoutType => 'a block without \"type\".';
@override
String get pathImportMalformedLevel => 'a heading with a malformed level.';
@override
String get pathImportMalformedListItem => 'a malformed list item.';
@override
String get pathImportMalformedMath => 'a malformed math block.';
@override
String get pathImportMalformedCode => 'a malformed code block.';
@override
String pathImportUnknownBlock(String detail) {
return 'an unknown block: $detail.';
}
@override
String pathImportTextlessBlock(String detail) {
return 'a $detail without \"text\" or \"runs\".';
}
@override
String get pathImportMalformedRun => 'a malformed run of text.';
@override
String get pathImportListExpected => 'a list was expected.';
@override
String pathImportNotText(String detail) {
return '\"$detail\" must be text.';
}
@override
String pathImportTooFew(String detail) {
return 'it needs at least two \"$detail\".';
}
@override
String get pathImportMalformedNumber => 'a malformed number.';
@override
String get pathImportTitleNotText => 'a title must be text.';
} }
/// 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

@ -50,7 +50,7 @@ class PathListBloc(
title: '', title: '',
steps: 0, steps: 0,
skippedBlocks: 0, skippedBlocks: 0,
failure: error.message, failure: error,
), ),
), ),
); );
@ -154,7 +154,7 @@ typedef ImportResult = ({
String title, String title,
int steps, int steps,
int skippedBlocks, int skippedBlocks,
String? failure, PathImportException? failure,
}); });
/// A studyset's paths, as the list shows them. /// A studyset's paths, as the list shows them.

View file

@ -35,13 +35,52 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_conte
// "image" and "lottie" are left out, keeping an "alt" caption: their files // "image" and "lottie" are left out, keeping an "alt" caption: their files
// would have to be fetched from the web, which the app never does. // would have to be fetched from the web, which the app never does.
/// A file in the import format that couldn't be read: [message] says why. /// What is wrong with a file in the import format.
enum PathImportProblem() {
notJson,
noPath,
noTitle,
descriptionNotText,
notAnObject,
unknownStepType,
rightOptionMissing,
rightOptionsMissing,
noPairing,
pairingTargetMissing,
pairingIncomplete,
noPassage,
blanksMismatch,
blockWithoutType,
malformedLevel,
malformedListItem,
malformedMath,
malformedCode,
unknownBlock,
textlessBlock,
malformedRun,
listExpected,
notText,
tooFew,
malformedNumber,
titleNotText,
}
/// A file in the import format that couldn't be read: the [problem], the
/// [step] it is in, and what it names ([detail]: a field, a type, a
/// number); the app says it in its language.
final class const PathImportException( final class const PathImportException(
/// What is wrong, and where. /// What is wrong.
final String message, final PathImportProblem problem, {
) implements Exception {
/// The step it is in, counting from 1; null for the path itself.
final int? step,
/// What the problem names, as written in the file.
final String detail = '',
}) implements Exception {
@override @override
String toString() => 'PathImportException: $message'; String toString() =>
'PathImportException: ${problem.name} in ${step ?? 'the path'}: $detail';
} }
/// A path read from the import format, not stored yet. /// A path read from the import format, not stored yet.
@ -81,33 +120,33 @@ ParsedPath parsePathImport(String source) {
try { try {
json = jsonDecode(source); json = jsonDecode(source);
} on FormatException catch (error) { } on FormatException catch (error) {
throw PathImportException('This is no JSON: ${error.message}.'); throw PathImportException(PathImportProblem.notJson, detail: error.message);
} }
final path = switch (json) { final path = switch (json) {
{'path': final Map<String, Object?> path} => path, {'path': final Map<String, Object?> path} => path,
_ => throw const PathImportException('A "path" object is missing.'), _ => throw const PathImportException(PathImportProblem.noPath),
}; };
final title = switch (path['title']) { final title = switch (path['title']) {
final String title when title.trim().isNotEmpty => title, final String title when title.trim().isNotEmpty => title,
_ => throw const PathImportException('The path has no "title".'), _ => throw const PathImportException(PathImportProblem.noTitle),
}; };
final blocks = _Blocks(); final blocks = _Blocks();
final steps = [ final steps = [
for (final (index, step) in _list( for (final (index, step) in _list(
path['steps'], path['steps'],
'the path\'s "steps"', null,
).indexed) ).indexed)
_step(step, index + 1, blocks), _step(step, index + 1, blocks),
]; ];
return ( return (
content: LearningPathContent( content: LearningPathContent(
title: title, title: title,
secondaryTitle: _optionalString(path['secondary_title'], 'the path'), secondaryTitle: _optionalString(path['secondary_title'], null),
description: switch (path['description']) { description: switch (path['description']) {
null => RichContent.empty(), null => RichContent.empty(),
final String text => RichContent.plain(text), final String text => RichContent.plain(text),
_ => throw const PathImportException( _ => throw const PathImportException(
'The path\'s "description" is no text.', PathImportProblem.descriptionNotText,
), ),
}, },
).normalized, ).normalized,
@ -121,10 +160,10 @@ ParsedPath parsePathImport(String source) {
int number, int number,
_Blocks blocks, _Blocks blocks,
) { ) {
final where = 'step $number'; final where = number;
final step = switch (json) { final step = switch (json) {
final Map<String, Object?> step => step, final Map<String, Object?> step => step,
_ => throw PathImportException('The $where is no object.'), _ => throw PathImportException(PathImportProblem.notAnObject, step: where),
}; };
final kind = switch (step['type']) { final kind = switch (step['type']) {
'info' => StepKind.info, 'info' => StepKind.info,
@ -133,7 +172,9 @@ ParsedPath parsePathImport(String source) {
'drag_and_drop' => StepKind.dragAndDrop, 'drag_and_drop' => StepKind.dragAndDrop,
'fill_in_the_blank' => StepKind.fillInTheBlank, 'fill_in_the_blank' => StepKind.fillInTheBlank,
final type => throw PathImportException( final type => throw PathImportException(
'The $where has an unknown "type": $type.', PathImportProblem.unknownStepType,
step: where,
detail: '$type',
), ),
}; };
final exercise = switch (kind) { final exercise = switch (kind) {
@ -143,7 +184,9 @@ ParsedPath parsePathImport(String source) {
final correct = _int(step['correct_option_index'], where); final correct = _int(step['correct_option_index'], where);
if (correct >= options.length) { if (correct >= options.length) {
throw PathImportException( throw PathImportException(
"The $where's right option $correct is not among its options.", PathImportProblem.rightOptionMissing,
step: where,
detail: '$correct',
); );
} }
return SingleChoice(options: options, correct: correct); return SingleChoice(options: options, correct: correct);
@ -156,7 +199,8 @@ ParsedPath parsePathImport(String source) {
}; };
if (correct.any((index) => index >= options.length)) { if (correct.any((index) => index >= options.length)) {
throw PathImportException( throw PathImportException(
'The $where names a right option that is not among its options.', PathImportProblem.rightOptionsMissing,
step: where,
); );
} }
return MultipleChoice(options: options, correct: correct); return MultipleChoice(options: options, correct: correct);
@ -168,14 +212,17 @@ ParsedPath parsePathImport(String source) {
final pairing = switch (step['correct_pairing']) { final pairing = switch (step['correct_pairing']) {
final Map<String, Object?> pairing => pairing, final Map<String, Object?> pairing => pairing,
_ => throw PathImportException( _ => throw PathImportException(
'The $where has no "correct_pairing".', PathImportProblem.noPairing,
step: where,
), ),
}; };
for (final MapEntry(key: target, value: placed) in pairing.entries) { for (final MapEntry(key: target, value: placed) in pairing.entries) {
final at = int.tryParse(target); final at = int.tryParse(target);
if (at == null || at >= targets.length) { if (at == null || at >= targets.length) {
throw PathImportException( throw PathImportException(
"The $where pairs items with a target that isn't there: $target.", PathImportProblem.pairingTargetMissing,
step: where,
detail: target,
); );
} }
for (final item in _list(placed, where)) { for (final item in _list(placed, where)) {
@ -185,7 +232,8 @@ ParsedPath parsePathImport(String source) {
if (targetOf.length != items.length || if (targetOf.length != items.length ||
targetOf.keys.any((item) => item >= items.length)) { targetOf.keys.any((item) => item >= items.length)) {
throw PathImportException( throw PathImportException(
'The $where must pair every item with one target.', PathImportProblem.pairingIncomplete,
step: where,
); );
} }
return DragAndDrop( return DragAndDrop(
@ -199,7 +247,10 @@ ParsedPath parsePathImport(String source) {
StepKind.fillInTheBlank => () { StepKind.fillInTheBlank => () {
final passage = switch (step['passage']) { final passage = switch (step['passage']) {
final String passage => passage, final String passage => passage,
_ => throw PathImportException('The $where has no "passage".'), _ => throw PathImportException(
PathImportProblem.noPassage,
step: where,
),
}; };
final exercise = FillInTheBlank( final exercise = FillInTheBlank(
passage: passage, passage: passage,
@ -207,7 +258,8 @@ ParsedPath parsePathImport(String source) {
); );
if (!exercise.isComplete) { if (!exercise.isComplete) {
throw PathImportException( throw PathImportException(
'The $where needs one word in "blanks" for each ___ in its passage.', PathImportProblem.blanksMismatch,
step: where,
); );
} }
return exercise; return exercise;
@ -230,7 +282,7 @@ class _Blocks() {
/// How many blocks were left out. /// How many blocks were left out.
int skipped = 0; int skipped = 0;
RichContent read(Object? json, String where) { RichContent read(Object? json, int? where) {
if (json == null) { if (json == null) {
return RichContent.empty(); return RichContent.empty();
} }
@ -245,10 +297,13 @@ class _Blocks() {
} }
/// The editor's nodes for [block]; null for a block left out. /// The editor's nodes for [block]; null for a block left out.
List<Map<String, Object?>>? _nodes(Object? block, String where) { List<Map<String, Object?>>? _nodes(Object? block, int? where) {
final type = switch (block) { final type = switch (block) {
{'type': final String type} => type, {'type': final String type} => type,
_ => throw PathImportException('The $where has a block without "type".'), _ => throw PathImportException(
PathImportProblem.blockWithoutType,
step: where,
),
}; };
final json = block as Map<String, Object?>; final json = block as Map<String, Object?>;
switch (type) { switch (type) {
@ -259,7 +314,10 @@ class _Blocks() {
final level = switch (json['level']) { final level = switch (json['level']) {
final int level when level >= 1 && level <= 6 => level, final int level when level >= 1 && level <= 6 => level,
null => 2, null => 2,
_ => throw PathImportException('The $where has a malformed level.'), _ => throw PathImportException(
PathImportProblem.malformedLevel,
step: where,
),
}; };
return [ return [
{ {
@ -280,7 +338,8 @@ class _Blocks() {
final String text => _plain(text), final String text => _plain(text),
{'runs': final Object? runs} => _runs(runs, where), {'runs': final Object? runs} => _runs(runs, where),
_ => throw PathImportException( _ => throw PathImportException(
'The $where has a malformed list item.', PathImportProblem.malformedListItem,
step: where,
), ),
}, },
}, },
@ -289,7 +348,10 @@ class _Blocks() {
case 'math': case 'math':
final formula = json['formula']; final formula = json['formula'];
if (formula is! String) { if (formula is! String) {
throw PathImportException('The $where has a malformed math block.'); throw PathImportException(
PathImportProblem.malformedMath,
step: where,
);
} }
return [ return [
{ {
@ -300,7 +362,10 @@ class _Blocks() {
case 'code': case 'code':
final source = json['code']; final source = json['code'];
if (source is! String) { if (source is! String) {
throw PathImportException('The $where has a malformed code block.'); throw PathImportException(
PathImportProblem.malformedCode,
step: where,
);
} }
return [ return [
{ {
@ -334,14 +399,18 @@ class _Blocks() {
_ => null, _ => null,
}; };
default: default:
throw PathImportException('The $where has an unknown block: $type.'); throw PathImportException(
PathImportProblem.unknownBlock,
step: where,
detail: type,
);
} }
} }
Map<String, Object?> _text( Map<String, Object?> _text(
String type, String type,
Map<String, Object?> json, Map<String, Object?> json,
String where, int? where,
) => { ) => {
'type': type, 'type': type,
'data': { 'data': {
@ -349,7 +418,9 @@ class _Blocks() {
{'text': final String text} => _plain(text), {'text': final String text} => _plain(text),
{'runs': final Object? runs} => _runs(runs, where), {'runs': final Object? runs} => _runs(runs, where),
_ => throw PathImportException( _ => throw PathImportException(
'The $where has a $type without "text" or "runs".', PathImportProblem.textlessBlock,
step: where,
detail: type,
), ),
}, },
}, },
@ -359,7 +430,7 @@ class _Blocks() {
if (text.isNotEmpty) {'insert': text}, if (text.isNotEmpty) {'insert': text},
]; ];
List<Map<String, Object?>> _runs(Object? json, String where) => [ List<Map<String, Object?>> _runs(Object? json, int? where) => [
for (final run in _list(json, where)) for (final run in _list(json, where))
switch (run) { switch (run) {
{'math': final String formula} => { {'math': final String formula} => {
@ -376,38 +447,52 @@ class _Blocks() {
case final attributes when attributes.isNotEmpty) case final attributes when attributes.isNotEmpty)
'attributes': attributes, 'attributes': attributes,
}, },
_ => throw PathImportException('The $where has a malformed run.'), _ => throw PathImportException(
PathImportProblem.malformedRun,
step: where,
),
}, },
]; ];
} }
List<Object?> _list(Object? json, String where) => switch (json) { List<Object?> _list(Object? json, int? where) => switch (json) {
final List<Object?> list => list, final List<Object?> list => list,
null => const [], null => const [],
_ => throw PathImportException('In $where, a list was expected.'), _ => throw PathImportException(PathImportProblem.listExpected, step: where),
}; };
List<String> _strings(Object? json, String where, String field) { List<String> _strings(Object? json, int? where, String field) {
final strings = [ final strings = [
for (final value in _list(json, where)) for (final value in _list(json, where))
switch (value) { switch (value) {
final String text => text, final String text => text,
_ => throw PathImportException('The $where\'s "$field" must be text.'), _ => throw PathImportException(
PathImportProblem.notText,
step: where,
detail: field,
),
}, },
]; ];
if (strings.length < 2 && field != 'blanks' && field != 'items') { if (strings.length < 2 && field != 'blanks' && field != 'items') {
throw PathImportException('The $where needs at least two "$field".'); throw PathImportException(
PathImportProblem.tooFew,
step: where,
detail: field,
);
} }
return strings; return strings;
} }
int _int(Object? json, String where) => switch (json) { int _int(Object? json, int? where) => switch (json) {
final int number when number >= 0 => number, final int number when number >= 0 => number,
_ => throw PathImportException('The $where has a malformed number.'), _ => throw PathImportException(
PathImportProblem.malformedNumber,
step: where,
),
}; };
String? _optionalString(Object? json, String where) => switch (json) { String? _optionalString(Object? json, int? where) => switch (json) {
null => null, null => null,
final String text => text, final String text => text,
_ => throw PathImportException('In $where, a title must be text.'), _ => throw PathImportException(PathImportProblem.titleNotText, step: where),
}; };

View file

@ -0,0 +1,51 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_import.dart';
/// What is wrong with an import file, in the app's language.
extension PathImportTexts on AppLocalizations {
/// What [error] says is wrong, with the step it is in.
String pathImportProblemText(PathImportException error) {
final detail = error.detail;
final problem = switch (error.problem) {
PathImportProblem.notJson => pathImportNotJson(detail),
PathImportProblem.noPath => pathImportNoPath,
PathImportProblem.noTitle => pathImportNoTitle,
PathImportProblem.descriptionNotText => pathImportDescriptionNotText,
PathImportProblem.notAnObject => pathImportNotAnObject,
PathImportProblem.unknownStepType => pathImportUnknownStepType(detail),
PathImportProblem.rightOptionMissing => pathImportRightOptionMissing(
detail,
),
PathImportProblem.rightOptionsMissing => pathImportRightOptionsMissing,
PathImportProblem.noPairing => pathImportNoPairing,
PathImportProblem.pairingTargetMissing => pathImportPairingTargetMissing(
detail,
),
PathImportProblem.pairingIncomplete => pathImportPairingIncomplete,
PathImportProblem.noPassage => pathImportNoPassage,
PathImportProblem.blanksMismatch => pathImportBlanksMismatch,
PathImportProblem.blockWithoutType => pathImportBlockWithoutType,
PathImportProblem.malformedLevel => pathImportMalformedLevel,
PathImportProblem.malformedListItem => pathImportMalformedListItem,
PathImportProblem.malformedMath => pathImportMalformedMath,
PathImportProblem.malformedCode => pathImportMalformedCode,
PathImportProblem.unknownBlock => pathImportUnknownBlock(detail),
PathImportProblem.textlessBlock => pathImportTextlessBlock(detail),
PathImportProblem.malformedRun => pathImportMalformedRun,
PathImportProblem.listExpected => pathImportListExpected,
PathImportProblem.notText => pathImportNotText(detail),
PathImportProblem.tooFew => pathImportTooFew(detail),
PathImportProblem.malformedNumber => pathImportMalformedNumber,
PathImportProblem.titleNotText => pathImportTitleNotText,
};
final sentence = switch (error.step) {
final step? => pathImportAtStep(step, problem),
null => problem,
};
// The problems read as the rest of a sentence; standing alone, the
// first letter goes up.
return sentence.isEmpty
? sentence
: sentence[0].toUpperCase() + sentence.substring(1);
}
}

View file

@ -21,6 +21,7 @@ import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_importer.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart'; import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart'; import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_list_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_import_texts.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_list.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_list.dart';
import 'package:halkyon_learn_flutter/src/feature/note/widget/note_list.dart'; import 'package:halkyon_learn_flutter/src/feature/note/widget/note_list.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/bloc/pdf_list_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/pdf_source/bloc/pdf_list_bloc.dart';
@ -120,7 +121,9 @@ class const StudysetScreen({
context.read<ContentTabCubit>().show(StudysetTab.paths); context.read<ContentTabCubit>().show(StudysetTab.paths);
} }
final message = switch (result.failure) { final message = switch (result.failure) {
final failure? => l10n.pathImportFailed(failure), final failure? => l10n.pathImportFailed(
l10n.pathImportProblemText(failure),
),
null => [ null => [
l10n.pathImported(result.title, result.steps), l10n.pathImported(result.title, result.steps),
if (result.skippedBlocks > 0) if (result.skippedBlocks > 0)

View file

@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_list_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_import.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
@ -116,7 +117,7 @@ void main() {
bloc.add(const PathListEventImportRequested('{')); bloc.add(const PathListEventImportRequested('{'));
final failed = await until((s) => s.importResult?.failure != null); final failed = await until((s) => s.importResult?.failure != null);
expect(failed.importResult!.failure, contains('JSON')); expect(failed.importResult!.failure?.problem, PathImportProblem.notJson);
}); });
test('creating a path hands back its id, and lists it', () async { test('creating a path hands back its id, and lists it', () async {

View file

@ -1,9 +1,12 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart'; import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart'; import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_import.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_import.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_import_texts.dart';
import 'package:material_ui/material_ui.dart';
import '../../../support/fake_learning_path_gateway.dart'; import '../../../support/fake_learning_path_gateway.dart';
@ -158,49 +161,76 @@ void main() {
}); });
group('refuses, saying why,', () { group('refuses, saying why,', () {
void refuses(String source, String reason) => expect( void refuses(String source, PathImportProblem problem, {int? step}) =>
() => parsePathImport(source), expect(
throwsA( () => parsePathImport(source),
isA<PathImportException>().having( throwsA(
(e) => e.message, isA<PathImportException>()
'message', .having((e) => e.problem, 'problem', problem)
contains(reason), .having((e) => e.step, 'step', step),
), ),
), );
);
test('what is no JSON', () => refuses('{', 'JSON')); test('what is no JSON', () => refuses('{', PathImportProblem.notJson));
test('a path without a title', () { test('a path without a title', () {
refuses('{"path": {"steps": []}}', 'title'); refuses('{"path": {"steps": []}}', PathImportProblem.noTitle);
}); });
test('a step of an unknown type', () { test('a step of an unknown type', () {
refuses( refuses(
'{"path": {"title": "x", "steps": [{"title": "a", "type": "quiz"}]}}', '{"path": {"title": "x", "steps": [{"title": "a", "type": "quiz"}]}}',
'step 1', PathImportProblem.unknownStepType,
step: 1,
); );
}); });
test('a right option that is not there', () { test('a right option that is not there', () {
refuses( refuses(
'{"path": {"title": "x", "steps": [{"title": "a", ' '{"path": {"title": "x", "steps": [{"title": "a", '
'"type": "single_choice", "options": ["a", "b"], ' '"type": "single_choice", "options": ["a", "b"], '
'"correct_option_index": 5}]}}', '"correct_option_index": 5}]}}',
'step 1', PathImportProblem.rightOptionMissing,
step: 1,
); );
}); });
test('blanks that do not fit the gaps', () { test('blanks that do not fit the gaps', () {
refuses( refuses(
'{"path": {"title": "x", "steps": [{"title": "a", ' '{"path": {"title": "x", "steps": [{"title": "a", '
'"type": "fill_in_the_blank", "passage": "a ___ b ___", ' '"type": "fill_in_the_blank", "passage": "a ___ b ___", '
'"blanks": ["x"]}]}}', '"blanks": ["x"]}]}}',
'step 1', PathImportProblem.blanksMismatch,
step: 1,
); );
}); });
}); });
test("says what is wrong in the app's language", () {
const atStep = PathImportException(
PathImportProblem.unknownStepType,
step: 2,
detail: 'quiz',
);
const ofPath = PathImportException(PathImportProblem.noTitle);
final english = lookupAppLocalizations(const Locale('en', 'GB'));
final german = lookupAppLocalizations(const Locale('de'));
expect(
english.pathImportProblemText(atStep),
'Step 2: unknown "type": quiz.',
);
expect(english.pathImportProblemText(ofPath), 'The path has no "title".');
expect(
german.pathImportProblemText(atStep),
'Schritt 2: unbekannter „type“: quiz.',
);
expect(
german.pathImportProblemText(ofPath),
'Der Pfad hat keinen „title“.',
);
});
test('imports a path with its steps into a studyset', () async { test('imports a path with its steps into a studyset', () async {
final paths = LearningPathRepository(FakeLearningPathGateway()); final paths = LearningPathRepository(FakeLearningPathGateway());
addTearDown(paths.dispose); addTearDown(paths.dispose);