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:
parent
918e10bb2b
commit
1921043925
12 changed files with 670 additions and 68 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`).
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue