feat(paths): path files carry tables, callouts and furigana, and several import at once
The import format gains the blocks lessons need: tables (rows of cells), callouts (a kind, a title, blocks inside, but no table or callout), spoilers, quotes and dividers; and runs of text gain a reading shown as furigana, a colour, underline and strikethrough. A line break is a "\n" in the text. Still simple blocks turned into rich text here, so a change of the editor's own format never breaks a file written before. A table's columns are as wide as their longest line, narrowed alike to fit a phone, where the cells wrap. A studyset's Import › Learning paths now takes several files at once, one path each, stored in the order of their names, so a chapter's lessons come in with one pick. Every file is read before anything is stored: one that can't be read is named, and nothing comes in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
277994d31b
commit
7d99feca7f
15 changed files with 781 additions and 112 deletions
|
|
@ -96,6 +96,8 @@
|
|||
"pathImported": "{count, plural, =1{„{title}“ mit 1 Schritt importiert.} other{„{title}“ mit {count} Schritten importiert.}}",
|
||||
"pathImportSkipped": "{count, plural, =1{1 Block wurde ausgelassen: Bilder und Animationen werden nicht importiert.} other{{count} Blöcke wurden ausgelassen: Bilder und Animationen werden nicht importiert.}}",
|
||||
"pathImportFailed": "Diese Datei konnte nicht importiert werden. {reason}",
|
||||
"pathImportFileFailed": "{file} konnte nicht importiert werden. {reason}",
|
||||
"pathsImported": "{steps, plural, =0{{count} Pfade ohne Schritte importiert.} =1{{count} Pfade mit 1 Schritt importiert.} other{{count} Pfade mit {steps} Schritten importiert.}}",
|
||||
"pathImportUnreadable": "Der Inhalt ist kein Text in UTF-8.",
|
||||
"pathsEmpty": "Noch keine Pfade",
|
||||
"pathsEmptyHint": "Ein Pfad führt Schritt für Schritt durch eine Lektion. Nutze „Material hinzufügen → Pfad erstellen“, um einen zu schreiben, oder „⋮ → Inhalt importieren“, um einen zu importieren.",
|
||||
|
|
@ -277,8 +279,8 @@
|
|||
"exportVideo": "YouTube-Video",
|
||||
"exportSpoiler": "Spoiler",
|
||||
"exportUnavailable": "nicht auf diesem Gerät verfügbar",
|
||||
"studysetImportPath": "Lernpfad",
|
||||
"studysetImportPathHint": "Aus einer Halkyon-Pfad-JSON-Datei",
|
||||
"studysetImportPath": "Lernpfade",
|
||||
"studysetImportPathHint": "Aus Halkyon-Pfad-JSON-Dateien, ein Pfad je Datei",
|
||||
"studysetImportAnki": "Anki-Deck",
|
||||
"studysetImportAnkiHint": "Aus einer .apkg- oder .colpkg-Datei",
|
||||
"studysetImportCards": "Karteikarten",
|
||||
|
|
@ -1329,6 +1331,9 @@
|
|||
"pathImportMalformedNumber": "eine fehlerhafte Zahl.",
|
||||
"pathImportTitleNotText": "ein Titel muss Text sein.",
|
||||
"pathImportMalformedSource": "eine Quelle muss ein Objekt oder Text sein und ihre Teile Text.",
|
||||
"pathImportMalformedTable": "eine Tabelle braucht „rows“ aus Zellen, jede so lang wie die erste.",
|
||||
"pathImportMalformedCallout": "eine Hinweisbox mit fehlerhaftem „{detail}“.",
|
||||
"pathImportNotInCallout": "ein „{detail}“ kann nicht in einer Hinweisbox stehen.",
|
||||
"pomodoroTopBarHint": "Der Timer kann in der oberen Leiste von GNOME herunterzählen, mit seinen Knöpfen, sobald die Erweiterung „AppIndicator“ an ist.",
|
||||
"pomodoroTopBarSetUp": "Einrichten",
|
||||
"pomodoroTopBarDismiss": "Nicht mehr vorschlagen",
|
||||
|
|
|
|||
|
|
@ -111,6 +111,10 @@
|
|||
"@pathImportSkipped": {"placeholders": {"count": {"type": "int"}}},
|
||||
"pathImportFailed": "Couldn't import this file. {reason}",
|
||||
"@pathImportFailed": {"placeholders": {"reason": {"type": "String"}}},
|
||||
"pathImportFileFailed": "Couldn't import {file}. {reason}",
|
||||
"@pathImportFileFailed": {"placeholders": {"file": {"type": "String"}, "reason": {"type": "String"}}},
|
||||
"pathsImported": "{steps, plural, =0{Imported {count} paths without steps.} =1{Imported {count} paths with 1 step.} other{Imported {count} paths with {steps} steps.}}",
|
||||
"@pathsImported": {"placeholders": {"count": {"type": "int"}, "steps": {"type": "int"}}},
|
||||
"pathImportUnreadable": "It isn't text in UTF-8.",
|
||||
"pathsEmpty": "No paths yet",
|
||||
"pathsEmptyHint": "A path leads through a lesson, step by step. Use \"Add material → Create a path\" to write one, or \"⋮ → Import content\" to import one.",
|
||||
|
|
@ -311,8 +315,8 @@
|
|||
"exportVideo": "YouTube video",
|
||||
"exportSpoiler": "Spoiler",
|
||||
"exportUnavailable": "not available on this device",
|
||||
"studysetImportPath": "Learning path",
|
||||
"studysetImportPathHint": "From a Halkyon path JSON file",
|
||||
"studysetImportPath": "Learning paths",
|
||||
"studysetImportPathHint": "From Halkyon path JSON files, one path each",
|
||||
"studysetImportAnki": "Anki deck",
|
||||
"studysetImportAnkiHint": "From an .apkg or .colpkg file",
|
||||
"studysetImportCards": "Flashcards",
|
||||
|
|
@ -1553,6 +1557,11 @@
|
|||
"pathImportMalformedNumber": "a malformed number.",
|
||||
"pathImportTitleNotText": "a title must be text.",
|
||||
"pathImportMalformedSource": "a source must be an object or text, and its parts text.",
|
||||
"pathImportMalformedTable": "a table needs \"rows\" of cells, each as long as the first.",
|
||||
"pathImportMalformedCallout": "a callout with a malformed {detail}.",
|
||||
"@pathImportMalformedCallout": {"placeholders": {"detail": {"type": "String"}}},
|
||||
"pathImportNotInCallout": "a {detail} can't be inside a callout.",
|
||||
"@pathImportNotInCallout": {"placeholders": {"detail": {"type": "String"}}},
|
||||
"pomodoroTopBarHint": "The timer can count down in GNOME's top bar, with its buttons, once the AppIndicator extension is on.",
|
||||
"pomodoroTopBarSetUp": "Set up",
|
||||
"pomodoroTopBarDismiss": "Don't suggest again",
|
||||
|
|
|
|||
|
|
@ -634,6 +634,18 @@ abstract class AppLocalizations {
|
|||
/// **'Couldn\'t import this file. {reason}'**
|
||||
String pathImportFailed(String reason);
|
||||
|
||||
/// No description provided for @pathImportFileFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Couldn\'t import {file}. {reason}'**
|
||||
String pathImportFileFailed(String file, String reason);
|
||||
|
||||
/// No description provided for @pathsImported.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{steps, plural, =0{Imported {count} paths without steps.} =1{Imported {count} paths with 1 step.} other{Imported {count} paths with {steps} steps.}}'**
|
||||
String pathsImported(int count, int steps);
|
||||
|
||||
/// No description provided for @pathImportUnreadable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -1723,13 +1735,13 @@ abstract class AppLocalizations {
|
|||
/// No description provided for @studysetImportPath.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Learning path'**
|
||||
/// **'Learning paths'**
|
||||
String get studysetImportPath;
|
||||
|
||||
/// No description provided for @studysetImportPathHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'From a Halkyon path JSON file'**
|
||||
/// **'From Halkyon path JSON files, one path each'**
|
||||
String get studysetImportPathHint;
|
||||
|
||||
/// No description provided for @studysetImportAnki.
|
||||
|
|
@ -7869,6 +7881,24 @@ abstract class AppLocalizations {
|
|||
/// **'a source must be an object or text, and its parts text.'**
|
||||
String get pathImportMalformedSource;
|
||||
|
||||
/// No description provided for @pathImportMalformedTable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'a table needs \"rows\" of cells, each as long as the first.'**
|
||||
String get pathImportMalformedTable;
|
||||
|
||||
/// No description provided for @pathImportMalformedCallout.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'a callout with a malformed {detail}.'**
|
||||
String pathImportMalformedCallout(String detail);
|
||||
|
||||
/// No description provided for @pathImportNotInCallout.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'a {detail} can\'t be inside a callout.'**
|
||||
String pathImportNotInCallout(String detail);
|
||||
|
||||
/// No description provided for @pomodoroTopBarHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -395,6 +395,23 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
return 'Diese Datei konnte nicht importiert werden. $reason';
|
||||
}
|
||||
|
||||
@override
|
||||
String pathImportFileFailed(String file, String reason) {
|
||||
return '$file konnte nicht importiert werden. $reason';
|
||||
}
|
||||
|
||||
@override
|
||||
String pathsImported(int count, int steps) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
steps,
|
||||
locale: localeName,
|
||||
other: '$count Pfade mit $steps Schritten importiert.',
|
||||
one: '$count Pfade mit 1 Schritt importiert.',
|
||||
zero: '$count Pfade ohne Schritte importiert.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pathImportUnreadable => 'Der Inhalt ist kein Text in UTF-8.';
|
||||
|
||||
|
|
@ -1052,10 +1069,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get exportUnavailable => 'nicht auf diesem Gerät verfügbar';
|
||||
|
||||
@override
|
||||
String get studysetImportPath => 'Lernpfad';
|
||||
String get studysetImportPath => 'Lernpfade';
|
||||
|
||||
@override
|
||||
String get studysetImportPathHint => 'Aus einer Halkyon-Pfad-JSON-Datei';
|
||||
String get studysetImportPathHint =>
|
||||
'Aus Halkyon-Pfad-JSON-Dateien, ein Pfad je Datei';
|
||||
|
||||
@override
|
||||
String get studysetImportAnki => 'Anki-Deck';
|
||||
|
|
@ -4895,6 +4913,20 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get pathImportMalformedSource =>
|
||||
'eine Quelle muss ein Objekt oder Text sein und ihre Teile Text.';
|
||||
|
||||
@override
|
||||
String get pathImportMalformedTable =>
|
||||
'eine Tabelle braucht „rows“ aus Zellen, jede so lang wie die erste.';
|
||||
|
||||
@override
|
||||
String pathImportMalformedCallout(String detail) {
|
||||
return 'eine Hinweisbox mit fehlerhaftem „$detail“.';
|
||||
}
|
||||
|
||||
@override
|
||||
String pathImportNotInCallout(String detail) {
|
||||
return 'ein „$detail“ kann nicht in einer Hinweisbox stehen.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pomodoroTopBarHint =>
|
||||
'Der Timer kann in der oberen Leiste von GNOME herunterzählen, mit seinen Knöpfen, sobald die Erweiterung „AppIndicator“ an ist.';
|
||||
|
|
|
|||
|
|
@ -387,6 +387,23 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
return 'Couldn\'t import this file. $reason';
|
||||
}
|
||||
|
||||
@override
|
||||
String pathImportFileFailed(String file, String reason) {
|
||||
return 'Couldn\'t import $file. $reason';
|
||||
}
|
||||
|
||||
@override
|
||||
String pathsImported(int count, int steps) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
steps,
|
||||
locale: localeName,
|
||||
other: 'Imported $count paths with $steps steps.',
|
||||
one: 'Imported $count paths with 1 step.',
|
||||
zero: 'Imported $count paths without steps.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pathImportUnreadable => 'It isn\'t text in UTF-8.';
|
||||
|
||||
|
|
@ -1043,10 +1060,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get exportUnavailable => 'not available on this device';
|
||||
|
||||
@override
|
||||
String get studysetImportPath => 'Learning path';
|
||||
String get studysetImportPath => 'Learning paths';
|
||||
|
||||
@override
|
||||
String get studysetImportPathHint => 'From a Halkyon path JSON file';
|
||||
String get studysetImportPathHint =>
|
||||
'From Halkyon path JSON files, one path each';
|
||||
|
||||
@override
|
||||
String get studysetImportAnki => 'Anki deck';
|
||||
|
|
@ -4851,6 +4869,20 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get pathImportMalformedSource =>
|
||||
'a source must be an object or text, and its parts text.';
|
||||
|
||||
@override
|
||||
String get pathImportMalformedTable =>
|
||||
'a table needs \"rows\" of cells, each as long as the first.';
|
||||
|
||||
@override
|
||||
String pathImportMalformedCallout(String detail) {
|
||||
return 'a callout with a malformed $detail.';
|
||||
}
|
||||
|
||||
@override
|
||||
String pathImportNotInCallout(String detail) {
|
||||
return 'a $detail can\'t be inside a callout.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pomodoroTopBarHint =>
|
||||
'The timer can count down in GNOME\'s top bar, with its buttons, once the AppIndicator extension is on.';
|
||||
|
|
|
|||
|
|
@ -14,6 +14,13 @@ abstract interface class FilePicker() {
|
|||
required String label,
|
||||
required List<String> extensions,
|
||||
});
|
||||
|
||||
/// The files the person picked, of a type [label] names with
|
||||
/// [extensions]; none if they picked none.
|
||||
Future<List<PickedFile>> pickMany({
|
||||
required String label,
|
||||
required List<String> extensions,
|
||||
});
|
||||
}
|
||||
|
||||
/// [FilePicker] with the platform's own dialog (`file_selector`).
|
||||
|
|
@ -24,19 +31,32 @@ class FileSelectorFilePicker() implements FilePicker {
|
|||
required List<String> extensions,
|
||||
}) async {
|
||||
final file = await openFile(
|
||||
acceptedTypeGroups: [
|
||||
// Android filters by media type, which extensions like Anki's lack,
|
||||
// so it offers every file there; what isn't one is refused later.
|
||||
if (!Platform.isAndroid)
|
||||
XTypeGroup(
|
||||
label: label,
|
||||
extensions: extensions,
|
||||
uniformTypeIdentifiers: const ['public.data'],
|
||||
),
|
||||
],
|
||||
acceptedTypeGroups: _typeGroups(label, extensions),
|
||||
);
|
||||
return file == null
|
||||
? null
|
||||
: (name: file.name, bytes: await file.readAsBytes());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PickedFile>> pickMany({
|
||||
required String label,
|
||||
required List<String> extensions,
|
||||
}) async => [
|
||||
for (final file in await openFiles(
|
||||
acceptedTypeGroups: _typeGroups(label, extensions),
|
||||
))
|
||||
(name: file.name, bytes: await file.readAsBytes()),
|
||||
];
|
||||
|
||||
List<XTypeGroup> _typeGroups(String label, List<String> extensions) => [
|
||||
// Android filters by media type, which extensions like Anki's lack, so
|
||||
// it offers every file there; what isn't one is refused later.
|
||||
if (!Platform.isAndroid)
|
||||
XTypeGroup(
|
||||
label: label,
|
||||
extensions: extensions,
|
||||
uniformTypeIdentifiers: const ['public.data'],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,35 +31,50 @@ class PathListBloc(
|
|||
});
|
||||
on<PathListEventImportRequested>((event, emit) async {
|
||||
emit(state.copyWith(importing: true));
|
||||
try {
|
||||
final parsed = parsePathImport(event.source);
|
||||
final imported = await PathImporter(
|
||||
_paths,
|
||||
).import(_studysetId, event.source);
|
||||
emit(
|
||||
state.copyWith(
|
||||
importing: false,
|
||||
importResult: (
|
||||
title: parsed.content.title,
|
||||
steps: imported.steps,
|
||||
skippedBlocks: imported.skippedBlocks,
|
||||
failure: null,
|
||||
// Every file is read before anything is stored, so one that can't be
|
||||
// read leaves the studyset as it was.
|
||||
final parsed = <ParsedPath>[];
|
||||
for (final file in event.files) {
|
||||
try {
|
||||
parsed.add(parsePathImport(file.source));
|
||||
} on PathImportException catch (error) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
importing: false,
|
||||
importResult: (
|
||||
title: '',
|
||||
paths: 0,
|
||||
steps: 0,
|
||||
skippedBlocks: 0,
|
||||
file: event.files.length > 1 ? file.name : null,
|
||||
failure: error,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
} on PathImportException catch (error) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
importing: false,
|
||||
importResult: (
|
||||
title: '',
|
||||
steps: 0,
|
||||
skippedBlocks: 0,
|
||||
failure: error,
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
final importer = PathImporter(_paths);
|
||||
var steps = 0;
|
||||
var skippedBlocks = 0;
|
||||
for (final path in parsed) {
|
||||
final imported = await importer.store(_studysetId, path);
|
||||
steps += imported.steps;
|
||||
skippedBlocks += imported.skippedBlocks;
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
importing: false,
|
||||
importResult: (
|
||||
title: parsed.firstOrNull?.content.title ?? '',
|
||||
paths: parsed.length,
|
||||
steps: steps,
|
||||
skippedBlocks: skippedBlocks,
|
||||
file: null,
|
||||
failure: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
on<PathListEventMoveRequested>((event, emit) {
|
||||
// The list takes the new order at once; the stored one follows.
|
||||
|
|
@ -177,15 +192,21 @@ class PathListBloc(
|
|||
}
|
||||
}
|
||||
|
||||
/// How an import went: the path's title, how many steps came in and how
|
||||
/// many blocks were left out; or, with a `failure`, why nothing came in.
|
||||
/// How an import went: the first path's title, how many paths and steps
|
||||
/// came in and how many blocks were left out; or, with a `failure`, why
|
||||
/// nothing came in, and of several files, the `file` it is in.
|
||||
typedef ImportResult = ({
|
||||
String title,
|
||||
int paths,
|
||||
int steps,
|
||||
int skippedBlocks,
|
||||
String? file,
|
||||
PathImportException? failure,
|
||||
});
|
||||
|
||||
/// A file in the paths' import format: its name and its text.
|
||||
typedef PathFile = ({String name, String source});
|
||||
|
||||
/// A studyset's paths, as the list shows them.
|
||||
final class const PathListState({
|
||||
/// Whether the paths were read.
|
||||
|
|
@ -283,13 +304,13 @@ final class const PathListEventCreateRequested(
|
|||
List<Object?> get props => [content];
|
||||
}
|
||||
|
||||
/// Import the path in [source], a file in the import format.
|
||||
/// Import the paths in [files], in the import format, in this order.
|
||||
final class const PathListEventImportRequested(
|
||||
/// The file's text.
|
||||
final String source,
|
||||
/// The files.
|
||||
final List<PathFile> files,
|
||||
) extends PathListEvent {
|
||||
@override
|
||||
List<Object?> get props => [source];
|
||||
List<Object?> get props => [files];
|
||||
}
|
||||
|
||||
/// Move the path [id] to [toIndex].
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
|
||||
|
|
@ -32,10 +33,16 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_conte
|
|||
// writes them ("citation" counts as "container", as files of before name
|
||||
// it), or text, kept as the citation it is.
|
||||
//
|
||||
// Blocks: {"type": "paragraph" | "heading" (with "level"), "text": "…"} or
|
||||
// with "runs": [{"text", "bold", "italic", "link", "math"}]; "bulleted_list"
|
||||
// and "numbered_list" with "items" (text or {"runs"}); "math" ("formula")
|
||||
// as a formula on its own line, and a run's "math" as one within the line;
|
||||
// Blocks: {"type": "paragraph" | "heading" (with "level") | "quote" |
|
||||
// "spoiler", "text": "…"} or with "runs": [{"text", "bold", "italic",
|
||||
// "underline", "strike", "link", "ruby" (the text's furigana), "color"
|
||||
// ("#rrggbb"), "math"}]; a line break is a "\n" in the text;
|
||||
// "bulleted_list" and "numbered_list" with "items" (text or {"runs"});
|
||||
// "divider"; "table" with "rows", each a list of cells (text or {"runs"}),
|
||||
// all as long; "callout" with "kind" (note, info, tip, success, question,
|
||||
// warning, danger, quote; note if missing), an optional "title" and its
|
||||
// "content" (blocks, but no table or callout); "math" ("formula") as a
|
||||
// formula on its own line, and a run's "math" as one within the line;
|
||||
// "code" ("code") as monospaced text, the app having no code blocks.
|
||||
// "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.
|
||||
|
|
@ -69,6 +76,9 @@ enum PathImportProblem() {
|
|||
malformedNumber,
|
||||
titleNotText,
|
||||
malformedSource,
|
||||
malformedTable,
|
||||
malformedCallout,
|
||||
notInCallout,
|
||||
}
|
||||
|
||||
/// A file in the import format that couldn't be read: the [problem], the
|
||||
|
|
@ -105,8 +115,12 @@ class PathImporter(final LearningPathRepository _paths) {
|
|||
/// Reads [source] and stores its path at the end of [studysetId], with its
|
||||
/// steps; a [PathImportException] for a file it can't read, before
|
||||
/// anything is stored.
|
||||
Future<ImportedPath> import(String studysetId, String source) async {
|
||||
final parsed = parsePathImport(source);
|
||||
Future<ImportedPath> import(String studysetId, String source) =>
|
||||
store(studysetId, parsePathImport(source));
|
||||
|
||||
/// Stores [parsed], a path read already, at the end of [studysetId], with
|
||||
/// its steps.
|
||||
Future<ImportedPath> store(String studysetId, ParsedPath parsed) async {
|
||||
final pathId = await _paths.createPath(studysetId, parsed.content);
|
||||
for (final (:kind, :content) in parsed.steps) {
|
||||
await _paths.createStep(pathId, kind, content);
|
||||
|
|
@ -138,10 +152,7 @@ ParsedPath parsePathImport(String source) {
|
|||
};
|
||||
final blocks = _Blocks();
|
||||
final steps = [
|
||||
for (final (index, step) in _list(
|
||||
path['steps'],
|
||||
null,
|
||||
).indexed)
|
||||
for (final (index, step) in _list(path['steps'], null).indexed)
|
||||
_step(step, index + 1, blocks),
|
||||
];
|
||||
return (
|
||||
|
|
@ -312,9 +323,7 @@ class _Blocks() {
|
|||
if (json == null) {
|
||||
return RichContent.empty();
|
||||
}
|
||||
final nodes = [
|
||||
for (final block in _list(json, where)) ...?_nodes(block, where),
|
||||
];
|
||||
final nodes = _all(json, where, inCallout: false);
|
||||
return nodes.isEmpty
|
||||
? RichContent.empty()
|
||||
: RichContent({
|
||||
|
|
@ -322,8 +331,22 @@ class _Blocks() {
|
|||
});
|
||||
}
|
||||
|
||||
/// The editor's nodes for the blocks in [json].
|
||||
List<Map<String, Object?>> _all(
|
||||
Object? json,
|
||||
int? where, {
|
||||
required bool inCallout,
|
||||
}) => [
|
||||
for (final block in _list(json, where))
|
||||
...?_nodes(block, where, inCallout: inCallout),
|
||||
];
|
||||
|
||||
/// The editor's nodes for [block]; null for a block left out.
|
||||
List<Map<String, Object?>>? _nodes(Object? block, int? where) {
|
||||
List<Map<String, Object?>>? _nodes(
|
||||
Object? block,
|
||||
int? where, {
|
||||
required bool inCallout,
|
||||
}) {
|
||||
final type = switch (block) {
|
||||
{'type': final String type} => type,
|
||||
_ => throw PathImportException(
|
||||
|
|
@ -354,6 +377,32 @@ class _Blocks() {
|
|||
},
|
||||
},
|
||||
];
|
||||
case 'quote':
|
||||
return [_text('quote', json, where)];
|
||||
case 'spoiler':
|
||||
return [_text(spoilerBlockType, json, where)];
|
||||
case 'divider':
|
||||
return [
|
||||
{'type': 'divider'},
|
||||
];
|
||||
case 'table':
|
||||
if (inCallout) {
|
||||
throw PathImportException(
|
||||
PathImportProblem.notInCallout,
|
||||
step: where,
|
||||
detail: type,
|
||||
);
|
||||
}
|
||||
return [_table(json, where)];
|
||||
case 'callout':
|
||||
if (inCallout) {
|
||||
throw PathImportException(
|
||||
PathImportProblem.notInCallout,
|
||||
step: where,
|
||||
detail: type,
|
||||
);
|
||||
}
|
||||
return [_callout(json, where)];
|
||||
case 'bulleted_list' || 'numbered_list':
|
||||
return [
|
||||
for (final item in _list(json['items'], where))
|
||||
|
|
@ -433,6 +482,138 @@ class _Blocks() {
|
|||
}
|
||||
}
|
||||
|
||||
/// A table: a cell for each column of each row, as the editor keeps
|
||||
/// them, by column and row.
|
||||
Map<String, Object?> _table(Map<String, Object?> json, int? where) {
|
||||
final rows = [
|
||||
for (final row in _list(json['rows'], where))
|
||||
[
|
||||
for (final cell in _list(row, where))
|
||||
switch (cell) {
|
||||
final String text => _plain(text),
|
||||
{'runs': final Object? runs} => _runs(runs, where),
|
||||
_ => throw PathImportException(
|
||||
PathImportProblem.malformedTable,
|
||||
step: where,
|
||||
),
|
||||
},
|
||||
],
|
||||
];
|
||||
final columns = rows.firstOrNull?.length ?? 0;
|
||||
if (columns == 0 || rows.any((row) => row.length != columns)) {
|
||||
throw PathImportException(PathImportProblem.malformedTable, step: where);
|
||||
}
|
||||
final natural = [
|
||||
for (var column = 0; column < columns; column++)
|
||||
_columnWidth([for (final row in rows) row[column]]),
|
||||
];
|
||||
// A table wider than a phone's text narrows, every column alike, its
|
||||
// cells wrapping their lines.
|
||||
final total = natural.fold<double>(0, (sum, width) => sum + width);
|
||||
final scale = min(1, _phoneWidth / total);
|
||||
final widths = [
|
||||
for (final width in natural)
|
||||
max(_narrowest, width * scale).roundToDouble(),
|
||||
];
|
||||
return {
|
||||
'type': 'table',
|
||||
// As the editor sizes a table it makes: its rows and columns, the
|
||||
// height of a row and the width a column takes at least.
|
||||
'data': {
|
||||
'colsLen': columns,
|
||||
'rowsLen': rows.length,
|
||||
'colDefaultWidth': 160.0,
|
||||
'rowDefaultHeight': 40.0,
|
||||
'colMinimumWidth': 40.0,
|
||||
},
|
||||
'children': [
|
||||
for (var column = 0; column < columns; column++)
|
||||
for (var row = 0; row < rows.length; row++)
|
||||
{
|
||||
'type': 'table/cell',
|
||||
'data': {
|
||||
'colPosition': column,
|
||||
'rowPosition': row,
|
||||
'width': widths[column],
|
||||
},
|
||||
'children': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {'delta': rows[row][column]},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/// About how wide a column must be for its longest line, in logical
|
||||
/// pixels, as the editor keeps it on the column's cells: a wide character
|
||||
/// (Chinese, Japanese, Korean) an em of the editor's text, any other
|
||||
/// about half of one, with the cell's padding; at least as wide as a short
|
||||
/// word, at most as half a phone's width, wrapping longer lines.
|
||||
double _columnWidth(List<List<Map<String, Object?>>> cells) {
|
||||
var widest = 0.0;
|
||||
for (final delta in cells) {
|
||||
final text = [for (final op in delta) op['insert']]
|
||||
.whereType<String>()
|
||||
.join();
|
||||
for (final line in text.split('\n')) {
|
||||
var ems = 0.0;
|
||||
for (final rune in line.runes) {
|
||||
ems += rune >= 0x2e80 ? 1 : 0.6;
|
||||
}
|
||||
widest = max(widest, ems);
|
||||
}
|
||||
}
|
||||
return (widest * 16 + 24).clamp(_narrowest, 200).roundToDouble();
|
||||
}
|
||||
|
||||
/// How narrow a column gets: a short word.
|
||||
static const _narrowest = 48.0;
|
||||
|
||||
/// How wide a table may be on a phone, beside the step's margins.
|
||||
static const _phoneWidth = 312.0;
|
||||
|
||||
/// A callout of a kind, with a title or its kind's name, holding blocks.
|
||||
Map<String, Object?> _callout(Map<String, Object?> json, int? where) {
|
||||
final kind = switch (json['kind']) {
|
||||
null => calloutKinds.first,
|
||||
final String kind when calloutKinds.contains(kind) => kind,
|
||||
_ => throw PathImportException(
|
||||
PathImportProblem.malformedCallout,
|
||||
step: where,
|
||||
detail: 'kind',
|
||||
),
|
||||
};
|
||||
final title = switch (json['title']) {
|
||||
null => null,
|
||||
final String title => title.trim(),
|
||||
_ => throw PathImportException(
|
||||
PathImportProblem.malformedCallout,
|
||||
step: where,
|
||||
detail: 'title',
|
||||
),
|
||||
};
|
||||
final children = _all(json['content'], where, inCallout: true);
|
||||
return {
|
||||
'type': calloutBlockType,
|
||||
'data': {
|
||||
calloutKindKey: kind,
|
||||
if (title != null && title.isNotEmpty) calloutTitleKey: title,
|
||||
},
|
||||
// An empty callout gets a line to write in, as the editor's has.
|
||||
'children': children.isEmpty
|
||||
? [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {'delta': <Object?>[]},
|
||||
},
|
||||
]
|
||||
: children,
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, Object?> _text(
|
||||
String type,
|
||||
Map<String, Object?> json,
|
||||
|
|
@ -465,11 +646,7 @@ class _Blocks() {
|
|||
},
|
||||
{'text': final String text} => {
|
||||
'insert': text,
|
||||
if ({
|
||||
if (run['bold'] == true) 'bold': true,
|
||||
if (run['italic'] == true) 'italic': true,
|
||||
if (run['link'] case final String link) 'href': link,
|
||||
}
|
||||
if (_attributes(run as Map<String, Object?>, where)
|
||||
case final attributes when attributes.isNotEmpty)
|
||||
'attributes': attributes,
|
||||
},
|
||||
|
|
@ -481,6 +658,34 @@ class _Blocks() {
|
|||
];
|
||||
}
|
||||
|
||||
/// The editor's marks for a run of text: its style, link, reading and
|
||||
/// colour; a [PathImportException] for a malformed reading or colour.
|
||||
Map<String, Object?> _attributes(Map<String, Object?> run, int? where) {
|
||||
final reading = switch (run['ruby']) {
|
||||
null => null,
|
||||
final String reading => reading.trim(),
|
||||
_ => throw PathImportException(PathImportProblem.malformedRun, step: where),
|
||||
};
|
||||
final color = switch (run['color']) {
|
||||
null => null,
|
||||
final String color when _hexColor.hasMatch(color) =>
|
||||
'0xff${color.substring(1).toLowerCase()}',
|
||||
_ => throw PathImportException(PathImportProblem.malformedRun, step: where),
|
||||
};
|
||||
return {
|
||||
if (run['bold'] == true) 'bold': true,
|
||||
if (run['italic'] == true) 'italic': true,
|
||||
if (run['underline'] == true) 'underline': true,
|
||||
if (run['strike'] == true) 'strikethrough': true,
|
||||
if (run['link'] case final String link) 'href': link,
|
||||
if (reading != null && reading.isNotEmpty) rubyKey: reading,
|
||||
// As the editor keeps a text colour: opaque, in hexadecimal.
|
||||
'font_color': ?color,
|
||||
};
|
||||
}
|
||||
|
||||
final _hexColor = RegExp(r'^#[0-9a-fA-F]{6}$');
|
||||
|
||||
List<Object?> _list(Object? json, int? where) => switch (json) {
|
||||
final List<Object?> list => list,
|
||||
null => const [],
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ extension PathImportTexts on AppLocalizations {
|
|||
PathImportProblem.malformedNumber => pathImportMalformedNumber,
|
||||
PathImportProblem.titleNotText => pathImportTitleNotText,
|
||||
PathImportProblem.malformedSource => pathImportMalformedSource,
|
||||
PathImportProblem.malformedTable => pathImportMalformedTable,
|
||||
PathImportProblem.malformedCallout => pathImportMalformedCallout(detail),
|
||||
PathImportProblem.notInCallout => pathImportNotInCallout(detail),
|
||||
};
|
||||
final sentence = switch (error.step) {
|
||||
final step? => pathImportAtStep(step, problem),
|
||||
|
|
|
|||
|
|
@ -158,12 +158,19 @@ class const StudysetScreen({
|
|||
if (result.failure == null) {
|
||||
context.read<ContentTabCubit>().show(StudysetTab.paths);
|
||||
}
|
||||
final message = switch (result.failure) {
|
||||
final failure? => l10n.pathImportFailed(
|
||||
final message = switch ((result.failure, result.file)) {
|
||||
(final failure?, final file?) => l10n.pathImportFileFailed(
|
||||
file,
|
||||
l10n.pathImportProblemText(failure),
|
||||
),
|
||||
null => [
|
||||
l10n.pathImported(result.title, result.steps),
|
||||
(final failure?, null) => l10n.pathImportFailed(
|
||||
l10n.pathImportProblemText(failure),
|
||||
),
|
||||
(null, _) => [
|
||||
if (result.paths == 1)
|
||||
l10n.pathImported(result.title, result.steps)
|
||||
else
|
||||
l10n.pathsImported(result.paths, result.steps),
|
||||
if (result.skippedBlocks > 0)
|
||||
l10n.pathImportSkipped(result.skippedBlocks),
|
||||
].join(' '),
|
||||
|
|
@ -1529,30 +1536,39 @@ enum _Import() {
|
|||
cards,
|
||||
}
|
||||
|
||||
/// Picks a file in the paths' import format and hands it to the paths'
|
||||
/// bloc, which says how it went.
|
||||
/// Picks files in the paths' import format, one path each, and hands them
|
||||
/// to the paths' bloc in the order of their names (a chapter's lessons
|
||||
/// numbered so), which says how it went.
|
||||
Future<void> _importPath(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final bloc = context.read<PathListBloc>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final file = await DependenciesScope.of(
|
||||
final picked = await DependenciesScope.of(
|
||||
context,
|
||||
).files.pick(label: l10n.pathImportFileType, extensions: const ['json']);
|
||||
if (file == null) {
|
||||
return;
|
||||
).files.pickMany(label: l10n.pathImportFileType, extensions: const ['json']);
|
||||
final files = <PathFile>[];
|
||||
for (final file in picked..sort((a, b) => a.name.compareTo(b.name))) {
|
||||
try {
|
||||
files.add((name: file.name, source: utf8.decode(file.bytes)));
|
||||
} on FormatException {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
picked.length == 1
|
||||
? l10n.pathImportFailed(l10n.pathImportUnreadable)
|
||||
: l10n.pathImportFileFailed(
|
||||
file.name,
|
||||
l10n.pathImportUnreadable,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
final String source;
|
||||
try {
|
||||
source = utf8.decode(file.bytes);
|
||||
} on FormatException {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.pathImportFailed(l10n.pathImportUnreadable)),
|
||||
),
|
||||
);
|
||||
return;
|
||||
if (files.isNotEmpty) {
|
||||
bloc.add(PathListEventImportRequested(files));
|
||||
}
|
||||
bloc.add(PathListEventImportRequested(source));
|
||||
}
|
||||
|
||||
/// Below expanded windows, at the bottom: Add material and Study, centred.
|
||||
|
|
|
|||
|
|
@ -100,26 +100,79 @@ void main() {
|
|||
await until((s) => s.loaded);
|
||||
|
||||
bloc.add(
|
||||
const PathListEventImportRequested(
|
||||
'{"path": {"title": "Particles", "steps": ['
|
||||
'{"title": "Read", "type": "info", "content": ['
|
||||
'{"type": "image", "src": "https://example.org/x.png"}]}]}}',
|
||||
),
|
||||
const PathListEventImportRequested([
|
||||
(
|
||||
name: 'particles.json',
|
||||
source:
|
||||
'{"path": {"title": "Particles", "steps": ['
|
||||
'{"title": "Read", "type": "info", "content": ['
|
||||
'{"type": "image", "src": "https://example.org/x.png"}]}]}}',
|
||||
),
|
||||
]),
|
||||
);
|
||||
await until((s) => s.importing);
|
||||
final imported = await until((s) => s.importResult != null);
|
||||
expect(imported.importing, isFalse);
|
||||
expect(imported.importResult, (
|
||||
title: 'Particles',
|
||||
paths: 1,
|
||||
steps: 1,
|
||||
skippedBlocks: 1,
|
||||
file: null,
|
||||
failure: null,
|
||||
));
|
||||
await until((s) => s.paths.any((p) => p.content.title == 'Particles'));
|
||||
|
||||
bloc.add(const PathListEventImportRequested('{'));
|
||||
bloc.add(
|
||||
const PathListEventImportRequested([(name: 'broken.json', source: '{')]),
|
||||
);
|
||||
final failed = await until((s) => s.importResult?.failure != null);
|
||||
expect(failed.importResult!.failure?.problem, PathImportProblem.notJson);
|
||||
expect(failed.importResult!.file, isNull);
|
||||
});
|
||||
|
||||
test('imports several paths in the order given, or none if one of the '
|
||||
'files is broken, naming it', () async {
|
||||
await until((s) => s.loaded);
|
||||
String path(String title) =>
|
||||
'{"path": {"title": "$title", "steps": ['
|
||||
'{"title": "Read", "type": "info"}, {"title": "More", "type": "info"}]}}';
|
||||
|
||||
bloc.add(
|
||||
PathListEventImportRequested([
|
||||
(name: '01.json', source: path('State-of-being')),
|
||||
(name: '02.json', source: '{'),
|
||||
]),
|
||||
);
|
||||
final failed = await until((s) => s.importResult?.failure != null);
|
||||
expect(failed.importResult!.file, '02.json');
|
||||
expect(failed.paths, isEmpty);
|
||||
|
||||
bloc.add(
|
||||
PathListEventImportRequested([
|
||||
(name: '01.json', source: path('State-of-being')),
|
||||
(name: '02.json', source: path('Particles')),
|
||||
]),
|
||||
);
|
||||
final imported = await until((s) => s.importResult?.paths == 2);
|
||||
expect(imported.importResult, (
|
||||
title: 'State-of-being',
|
||||
paths: 2,
|
||||
steps: 4,
|
||||
skippedBlocks: 0,
|
||||
file: null,
|
||||
failure: null,
|
||||
));
|
||||
final listed = await until((s) => s.paths.length == 2);
|
||||
expect(
|
||||
[for (final p in listed.paths) p.content.title],
|
||||
[
|
||||
'State-of-being',
|
||||
'Particles',
|
||||
],
|
||||
);
|
||||
// The list is read anew after each of the import's writes.
|
||||
await pumpEventQueue();
|
||||
});
|
||||
|
||||
test('creating a path hands back its id, and lists it', () async {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
||||
|
|
@ -161,6 +163,162 @@ void main() {
|
|||
expect(parsed.skippedBlocks, 2);
|
||||
});
|
||||
|
||||
test('reads tables, callouts, spoilers, quotes, dividers, and text with '
|
||||
'readings, colours, underlines and strikes', () {
|
||||
final parsed = parsePathImport(r'''
|
||||
{"path": {"title": "x", "steps": [{"title": "a", "type": "info", "content": [
|
||||
{"type": "paragraph", "runs": [
|
||||
{"text": "学生", "ruby": " がくせい "},
|
||||
{"text": "だ", "color": "#E8590C"},
|
||||
{"text": "い", "strike": true, "underline": true},
|
||||
{"text": "\n", "ruby": " "}]},
|
||||
{"type": "table", "rows": [["", {"runs": [{"text": "Positive", "bold": true}]}],
|
||||
["Past", "学生だった"]]},
|
||||
{"type": "callout", "kind": "info", "title": " Summary ", "content": [
|
||||
{"type": "numbered_list", "items": ["Attach だ"]}]},
|
||||
{"type": "callout", "content": []},
|
||||
{"type": "spoiler", "text": "これだ"},
|
||||
{"type": "quote", "text": "A: 元気?"},
|
||||
{"type": "divider"}
|
||||
]}]}}''');
|
||||
final document =
|
||||
parsed.steps.single.content.body.editorJson['document']!
|
||||
as Map<String, Object?>;
|
||||
|
||||
expect(document['children'], [
|
||||
paragraph([
|
||||
{
|
||||
'insert': '学生',
|
||||
'attributes': {rubyKey: 'がくせい'},
|
||||
},
|
||||
{
|
||||
'insert': 'だ',
|
||||
'attributes': {'font_color': '0xffe8590c'},
|
||||
},
|
||||
{
|
||||
'insert': 'い',
|
||||
'attributes': {'underline': true, 'strikethrough': true},
|
||||
},
|
||||
// A reading of nothing but spaces is none.
|
||||
{'insert': '\n'},
|
||||
]),
|
||||
{
|
||||
'type': 'table',
|
||||
'data': {
|
||||
'colsLen': 2,
|
||||
'rowsLen': 2,
|
||||
'colDefaultWidth': 160.0,
|
||||
'rowDefaultHeight': 40.0,
|
||||
'colMinimumWidth': 40.0,
|
||||
},
|
||||
// By column, then row, as the editor keeps them.
|
||||
'children': [
|
||||
for (final (column, row, delta) in [
|
||||
(0, 0, <Object?>[]),
|
||||
(
|
||||
0,
|
||||
1,
|
||||
[
|
||||
{'insert': 'Past'},
|
||||
],
|
||||
),
|
||||
(
|
||||
1,
|
||||
0,
|
||||
[
|
||||
{
|
||||
'insert': 'Positive',
|
||||
'attributes': {'bold': true},
|
||||
},
|
||||
],
|
||||
),
|
||||
(
|
||||
1,
|
||||
1,
|
||||
[
|
||||
{'insert': '学生だった'},
|
||||
],
|
||||
),
|
||||
])
|
||||
{
|
||||
'type': 'table/cell',
|
||||
'data': {
|
||||
'colPosition': column,
|
||||
'rowPosition': row,
|
||||
// Each column as wide as its longest line: "Past", four
|
||||
// narrow letters; "学生だった", five wide ones.
|
||||
'width': [62.0, 104.0][column],
|
||||
},
|
||||
'children': [paragraph(delta.cast())],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'type': calloutBlockType,
|
||||
'data': {calloutKindKey: 'info', calloutTitleKey: 'Summary'},
|
||||
'children': [
|
||||
{
|
||||
'type': 'numbered_list',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': 'Attach だ'},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// A note, with a line to write in.
|
||||
{
|
||||
'type': calloutBlockType,
|
||||
'data': {calloutKindKey: 'note'},
|
||||
'children': [paragraph([])],
|
||||
},
|
||||
{
|
||||
'type': spoilerBlockType,
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': 'これだ'},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
'type': 'quote',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': 'A: 元気?'},
|
||||
],
|
||||
},
|
||||
},
|
||||
{'type': 'divider'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('narrows a table wider than a phone, every column alike', () {
|
||||
final parsed = parsePathImport(
|
||||
'{"path": {"title": "x", "steps": [{"title": "a", "type": "info", '
|
||||
'"content": [{"type": "table", "rows": [["", "Positive", "", '
|
||||
'"Negative", ""], ["Non-Past", "学生(だ)", "Is student", '
|
||||
'"学生じゃない", "Is not student"]]}]}]}}',
|
||||
);
|
||||
final document =
|
||||
parsed.steps.single.content.body.editorJson['document']!
|
||||
as Map<String, Object?>;
|
||||
final table =
|
||||
(document['children']! as List<Object?>).single!
|
||||
as Map<String, Object?>;
|
||||
final widths = {
|
||||
for (final cell
|
||||
in (table['children']! as List<Object?>).cast<Map<String, Object?>>())
|
||||
(cell['data']! as Map<String, Object?>)['colPosition']:
|
||||
(cell['data']! as Map<String, Object?>)['width']! as double,
|
||||
};
|
||||
|
||||
expect(widths.values.reduce((a, b) => a + b), lessThanOrEqualTo(314));
|
||||
expect(widths.values, everyElement(greaterThanOrEqualTo(48)));
|
||||
// The widest column stays the widest.
|
||||
expect(widths[4], widths.values.reduce(max));
|
||||
});
|
||||
|
||||
test("reads a step's sources: their parts, \"citation\" as what they are "
|
||||
'part of, and text as a citation to keep as it is', () {
|
||||
final parsed = parsePathImport('''
|
||||
|
|
@ -233,6 +391,53 @@ void main() {
|
|||
}
|
||||
});
|
||||
|
||||
test('a table whose rows differ in length, or without rows', () {
|
||||
for (final rows in ['[["a", "b"], ["c"]]', '[]', '[[{"text": "a"}]]']) {
|
||||
refuses(
|
||||
'{"path": {"title": "x", "steps": [{"title": "a", "type": "info", '
|
||||
'"content": [{"type": "table", "rows": $rows}]}]}}',
|
||||
PathImportProblem.malformedTable,
|
||||
step: 1,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a callout of an unknown kind, or with a title that is no text', () {
|
||||
for (final callout in ['"kind": "aside"', '"title": 3']) {
|
||||
refuses(
|
||||
'{"path": {"title": "x", "steps": [{"title": "a", "type": "info", '
|
||||
'"content": [{"type": "callout", $callout}]}]}}',
|
||||
PathImportProblem.malformedCallout,
|
||||
step: 1,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a table or a callout inside a callout', () {
|
||||
for (final block in [
|
||||
'{"type": "table", "rows": [["a"]]}',
|
||||
'{"type": "callout"}',
|
||||
]) {
|
||||
refuses(
|
||||
'{"path": {"title": "x", "steps": [{"title": "a", "type": "info", '
|
||||
'"content": [{"type": "callout", "content": [$block]}]}]}}',
|
||||
PathImportProblem.notInCallout,
|
||||
step: 1,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a colour that is not #rrggbb, or a reading that is no text', () {
|
||||
for (final run in ['"color": "orange"', '"color": "#fff"', '"ruby": 1']) {
|
||||
refuses(
|
||||
'{"path": {"title": "x", "steps": [{"title": "a", "type": "info", '
|
||||
'"content": [{"type": "paragraph", "runs": [{"text": "a", $run}]}]}]}}',
|
||||
PathImportProblem.malformedRun,
|
||||
step: 1,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('blanks that do not fit the gaps', () {
|
||||
refuses(
|
||||
'{"path": {"title": "x", "steps": [{"title": "a", '
|
||||
|
|
@ -280,17 +485,8 @@ void main() {
|
|||
final stored = await paths.stepsOf(imported.pathId);
|
||||
expect(
|
||||
[for (final step in stored) step.content.title],
|
||||
[
|
||||
'Read',
|
||||
'One',
|
||||
'All',
|
||||
'Sort',
|
||||
'Fill',
|
||||
],
|
||||
);
|
||||
expect(
|
||||
(await paths.pathsOf('japanese')).single.content.title,
|
||||
'Particles',
|
||||
['Read', 'One', 'All', 'Sort', 'Fill'],
|
||||
);
|
||||
expect((await paths.pathsOf('japanese')).single.content.title, 'Particles');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
|
||||
|
|
@ -240,7 +241,7 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Import content'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Learning path'));
|
||||
await tester.tap(find.text('Learning paths'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(files.asked.single, ['json']);
|
||||
|
|
@ -254,6 +255,40 @@ void main() {
|
|||
expect(find.text('Particles'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("imports several paths at once, in the order of their files' "
|
||||
'names', (tester) async {
|
||||
Uint8List path(String title) => utf8.encode(
|
||||
'{"path": {"title": "$title", "steps": ['
|
||||
'{"title": "Read", "type": "info"}]}}',
|
||||
);
|
||||
files.nextMany = [
|
||||
(name: '02-particles.json', bytes: path('Particles')),
|
||||
(name: '01-state-of-being.json', bytes: path('State-of-being')),
|
||||
];
|
||||
await showApp(tester, RoutePaths.studyset('japanese'));
|
||||
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(AppBar),
|
||||
matching: find.byTooltip('Options'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Import content'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Learning paths'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.textContaining('Imported 2 paths with 2 steps.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester.getTopLeft(find.text('State-of-being')).dy,
|
||||
lessThan(tester.getTopLeft(find.text('Particles')).dy),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a note written from Add material shows in the Notes tab on '
|
||||
'coming back, as flashcards do in theirs', (tester) async {
|
||||
final paths = repository();
|
||||
|
|
|
|||
|
|
@ -428,7 +428,7 @@ void main() {
|
|||
|
||||
await choose(tester, 'Import content');
|
||||
expect(find.text('Anki deck'), findsOneWidget);
|
||||
expect(find.text('Learning path'), findsOneWidget);
|
||||
expect(find.text('Learning paths'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("a studyset's More info shows its id and its parent's, to "
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ class FakeFilePicker() implements FilePicker {
|
|||
/// What the next pick gives; null as if the person cancelled.
|
||||
PickedFile? next;
|
||||
|
||||
/// What the next pick of several gives; [next] alone if null.
|
||||
List<PickedFile>? nextMany;
|
||||
|
||||
/// The extensions of each pick asked for, in order.
|
||||
final asked = <List<String>>[];
|
||||
|
||||
|
|
@ -17,4 +20,13 @@ class FakeFilePicker() implements FilePicker {
|
|||
asked.add(extensions);
|
||||
return next;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PickedFile>> pickMany({
|
||||
required String label,
|
||||
required List<String> extensions,
|
||||
}) async {
|
||||
asked.add(extensions);
|
||||
return nextMany ?? [?next];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue