feat(learn): an Anki deck's import shows its steps, counts and percent, with Abort
Importing an Anki deck showed one bar and "Importing…". It now says which of its four steps it is in (copying files, writing flashcards, tagging, bringing over the review history), how many of the step's items are done and the percent of all of it, and offers Abort (decided 9 Nov 2026), as the archive's import does. The importer checks between files and batches of cards. Aborted, it deletes what it wrote: a studyset it made for the deck for good with all of it; importing into an existing studyset, the cards it wrote there and the topics it made for subdecks, leaving what the studyset held before; and the files' rows it made, not those the person had. Tags it made stay. Once everything is written, Abort no longer undoes it. Whats-New: Importing an Anki deck shows its steps and how far it has come, and Abort stops it, keeping nothing of it. Whats-New-De: Der Import eines Anki-Decks zeigt seine Schritte und wie weit er ist, und „Abbrechen“ beendet ihn, ohne etwas davon zu behalten. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
5f7cb70c3c
commit
7cba684513
10 changed files with 345 additions and 60 deletions
|
|
@ -1329,7 +1329,6 @@
|
|||
"ankiImportMedia": "{count, plural, =1{Medien übernehmen (1 Datei)} other{Medien übernehmen ({count} Dateien)}}",
|
||||
"ankiImportMediaSubtitle": "Bilder und Töne des Stapels auf den Karten behalten.",
|
||||
"ankiImportMediaMayNotFit": "Die Dateien belegen {size} MB, mehr als die {left} MB, die dir noch bleiben: Was nicht hineinpasst, bleibt auf diesem Gerät, und deine anderen Geräte zeigen es nicht an.",
|
||||
"ankiImportRunning": "Wird importiert…",
|
||||
"ankiImportConfirm": "Importieren",
|
||||
"ankiImportNotAPackage": "Das ist kein Anki-Stapel. Exportiere in Anki einen Stapel als .apkg-Datei und importiere diese.",
|
||||
"ankiImportNewerFormat": "Dieser Stapel stammt aus einer neueren Anki-Version, die diese App noch nicht kennt. Exportiere ihn erneut mit „Ältere Anki-Versionen unterstützen“.",
|
||||
|
|
|
|||
|
|
@ -1561,7 +1561,6 @@
|
|||
"left": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"ankiImportRunning": "Importing…",
|
||||
"ankiImportConfirm": "Import",
|
||||
"ankiImportNotAPackage": "This isn't an Anki deck. In Anki, export a deck as an .apkg file and import that.",
|
||||
"ankiImportNewerFormat": "This deck comes from a newer Anki than this app knows. Export it again with “Support older Anki versions”.",
|
||||
|
|
|
|||
|
|
@ -7846,12 +7846,6 @@ abstract class AppLocalizations {
|
|||
/// **'Its files take {size} MB, more than the {left} MB you have left: those that don\'t fit stay on this device, and your other devices won\'t show them.'**
|
||||
String ankiImportMediaMayNotFit(String size, String left);
|
||||
|
||||
/// No description provided for @ankiImportRunning.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Importing…'**
|
||||
String get ankiImportRunning;
|
||||
|
||||
/// No description provided for @ankiImportConfirm.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -4922,9 +4922,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
return 'Die Dateien belegen $size MB, mehr als die $left MB, die dir noch bleiben: Was nicht hineinpasst, bleibt auf diesem Gerät, und deine anderen Geräte zeigen es nicht an.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get ankiImportRunning => 'Wird importiert…';
|
||||
|
||||
@override
|
||||
String get ankiImportConfirm => 'Importieren';
|
||||
|
||||
|
|
|
|||
|
|
@ -4878,9 +4878,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
return 'Its files take $size MB, more than the $left MB you have left: those that don\'t fit stay on this device, and your other devices won\'t show them.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get ankiImportRunning => 'Importing…';
|
||||
|
||||
@override
|
||||
String get ankiImportConfirm => 'Import';
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'dart:typed_data';
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/work/work_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_importer.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.dart';
|
||||
|
|
@ -11,7 +12,8 @@ import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.
|
|||
/// Importing one Anki package into the studyset [_studysetId]: reading it,
|
||||
/// choosing how, and bringing it in. Without a studyset it goes into a new
|
||||
/// one of its own, made only once the import starts, so that closing the
|
||||
/// sheet before leaves nothing behind.
|
||||
/// sheet before leaves nothing behind. Aborted, it deletes what it wrote
|
||||
/// (decided 9 Nov 2026).
|
||||
class AnkiImportBloc(
|
||||
final AnkiPackageReader _reader,
|
||||
final AnkiImporter _importer,
|
||||
|
|
@ -26,8 +28,24 @@ class AnkiImportBloc(
|
|||
}
|
||||
});
|
||||
on<AnkiImportEventConfirmed>(_onConfirmed);
|
||||
on<AnkiImportEventAborted>((event, emit) {
|
||||
if (state case final AnkiImportStateRunning running
|
||||
when !running.aborting) {
|
||||
_abort.abort();
|
||||
emit(
|
||||
AnkiImportStateRunning(
|
||||
running.summary,
|
||||
running.options,
|
||||
running.progress,
|
||||
aborting: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
final _abort = AbortSignal();
|
||||
|
||||
AnkiPackage? _package;
|
||||
|
||||
/// The name of the package's file, without its extension.
|
||||
|
|
@ -86,7 +104,7 @@ class AnkiImportBloc(
|
|||
final package = _package;
|
||||
if (state case final AnkiImportStateReady ready when package != null) {
|
||||
final options = ready.options;
|
||||
emit(AnkiImportStateRunning(ready.summary, options, 0));
|
||||
emit(AnkiImportStateRunning(ready.summary, options, null));
|
||||
String? made;
|
||||
try {
|
||||
final result = await _importer.import(
|
||||
|
|
@ -101,10 +119,19 @@ class AnkiImportBloc(
|
|||
keepProgress: options.keepProgress && ready.summary.hasReviews,
|
||||
includeMedia: options.includeMedia,
|
||||
onProgress: (progress) => emit(
|
||||
AnkiImportStateRunning(ready.summary, options, progress),
|
||||
AnkiImportStateRunning(
|
||||
ready.summary,
|
||||
options,
|
||||
progress,
|
||||
aborting: _abort.aborted,
|
||||
),
|
||||
),
|
||||
abort: _abort,
|
||||
madeForIt: made != null,
|
||||
);
|
||||
emit(AnkiImportStateDone(result, options.mode));
|
||||
} on WorkAborted {
|
||||
emit(const AnkiImportStateAborted());
|
||||
} on Exception catch (error) {
|
||||
log('Importing an Anki package failed', name: 'Anki', error: error);
|
||||
if (made != null) {
|
||||
|
|
@ -217,6 +244,9 @@ final class const AnkiImportEventOptionsChanged(
|
|||
/// Bring the package in.
|
||||
final class const AnkiImportEventConfirmed() extends AnkiImportEvent;
|
||||
|
||||
/// Stop bringing it in, and delete what came.
|
||||
final class const AnkiImportEventAborted() extends AnkiImportEvent;
|
||||
|
||||
/// How far an import is.
|
||||
sealed class const AnkiImportState() with Equatable {
|
||||
@override
|
||||
|
|
@ -248,7 +278,8 @@ final class const AnkiImportStateReady(
|
|||
List<Object?> get props => [summary, options];
|
||||
}
|
||||
|
||||
/// Importing, [progress] from 0 to 1.
|
||||
/// Importing, as far as [progress], or, [aborting], deleting what it
|
||||
/// wrote.
|
||||
final class const AnkiImportStateRunning(
|
||||
/// What it holds.
|
||||
final AnkiImportSummary summary,
|
||||
|
|
@ -256,13 +287,17 @@ final class const AnkiImportStateRunning(
|
|||
/// How it is imported.
|
||||
final AnkiImportOptions options,
|
||||
|
||||
/// How far it got.
|
||||
final double progress,
|
||||
) extends AnkiImportState {
|
||||
/// How far it got; none told yet as it starts.
|
||||
final WorkProgress<AnkiImportStage>? progress, {
|
||||
final bool aborting = false,
|
||||
}) extends AnkiImportState {
|
||||
@override
|
||||
List<Object?> get props => [summary, options, progress];
|
||||
List<Object?> get props => [summary, options, progress, aborting];
|
||||
}
|
||||
|
||||
/// Aborted, what it wrote deleted.
|
||||
final class const AnkiImportStateAborted() extends AnkiImportState;
|
||||
|
||||
/// Imported.
|
||||
final class const AnkiImportStateDone(
|
||||
/// What it brought.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:halkyon_editor/halkyon_editor.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/media_type.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/work/work_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_field.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/anki/model/anki_collection.dart';
|
||||
|
|
@ -11,6 +13,7 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
|
|
@ -29,6 +32,14 @@ enum AnkiImportMode() {
|
|||
tagBySubdeck,
|
||||
}
|
||||
|
||||
/// The steps of an Anki import (decided 9 Nov 2026).
|
||||
enum AnkiImportStage() {
|
||||
files,
|
||||
cards,
|
||||
tags,
|
||||
reviews,
|
||||
}
|
||||
|
||||
/// What an import brought: the cards made, the notes left out (nothing on
|
||||
/// one side), the reviews brought over, and the files added and left out
|
||||
/// (a kind the app doesn't show, missing or too large).
|
||||
|
|
@ -47,13 +58,24 @@ const ankiOrigin = 'anki';
|
|||
/// note (its first field the front, the others the back; a cloze note's
|
||||
/// deletions hidden on the front), its tags, its files, and, if wanted, its
|
||||
/// review history, which the app's FSRS replays like its own.
|
||||
///
|
||||
/// Aborted, it deletes what it wrote (decided 9 Nov 2026): the cards, with
|
||||
/// their reviews, the studysets it made for decks, and the files' rows it
|
||||
/// made (not those the person had); the tags it made stay, as names the
|
||||
/// person may well use anyway.
|
||||
class AnkiImporter(
|
||||
final StudysetRepository _studysets,
|
||||
final CardRepository _cards,
|
||||
final TagRepository _tags,
|
||||
final ReviewRepository _reviews,
|
||||
final AssetRepository _assets,
|
||||
final StudysetDeletions _deletions,
|
||||
) {
|
||||
static final _stages = WorkStages(
|
||||
AnkiImportStage.values,
|
||||
weights: const {AnkiImportStage.cards: 3},
|
||||
);
|
||||
|
||||
/// Makes a studyset of its own at the top of the library for [package],
|
||||
/// named after the deck all its notes are in or below, or [fallbackTitle]
|
||||
/// for a package without one; returns its id, to import into.
|
||||
|
|
@ -80,37 +102,84 @@ class AnkiImporter(
|
|||
|
||||
/// Imports [package] into the studyset [studysetId] as [mode] says, its
|
||||
/// review history if [keepProgress], its files if [includeMedia];
|
||||
/// [onProgress] hears how far it got, from 0 to 1.
|
||||
/// [onProgress] hears how far it got.
|
||||
///
|
||||
/// Once [abort] is asked, it stops between items, deletes what it wrote
|
||||
/// (the studyset [studysetId] itself for good where it was [madeForIt],
|
||||
/// `newStudysetFor`) and throws [WorkAborted].
|
||||
Future<AnkiImportResult> import(
|
||||
AnkiPackage package, {
|
||||
required String studysetId,
|
||||
required AnkiImportMode mode,
|
||||
required bool keepProgress,
|
||||
required bool includeMedia,
|
||||
void Function(double progress)? onProgress,
|
||||
void Function(WorkProgress<AnkiImportStage> progress)? onProgress,
|
||||
AbortSignal? abort,
|
||||
bool madeForIt = false,
|
||||
}) async {
|
||||
final trail = _AnkiTrail();
|
||||
try {
|
||||
return await _import(
|
||||
package,
|
||||
studysetId: studysetId,
|
||||
mode: mode,
|
||||
keepProgress: keepProgress,
|
||||
includeMedia: includeMedia,
|
||||
onProgress: onProgress,
|
||||
abort: abort,
|
||||
trail: trail,
|
||||
);
|
||||
} on WorkAborted {
|
||||
if (madeForIt) {
|
||||
await _deletions.deleteForGood(studysetId);
|
||||
} else {
|
||||
// A studyset made for a deck holds only what the import wrote.
|
||||
for (final id in trail.studysets) {
|
||||
await _deletions.deleteForGood(id);
|
||||
}
|
||||
for (final id in trail.cards) {
|
||||
await _cards.deleteById(id);
|
||||
}
|
||||
}
|
||||
for (final id in trail.files) {
|
||||
await _assets.delete(id);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<AnkiImportResult> _import(
|
||||
AnkiPackage package, {
|
||||
required String studysetId,
|
||||
required AnkiImportMode mode,
|
||||
required bool keepProgress,
|
||||
required bool includeMedia,
|
||||
required void Function(WorkProgress<AnkiImportStage> progress)? onProgress,
|
||||
required AbortSignal? abort,
|
||||
required _AnkiTrail trail,
|
||||
}) async {
|
||||
final notes = package.collection.notes;
|
||||
final media = includeMedia
|
||||
? referencedMedia(package.collection)
|
||||
: <String>[];
|
||||
final steps = media.length + notes.length + 2;
|
||||
var done = 0;
|
||||
void step([int count = 1]) {
|
||||
done += count;
|
||||
onProgress?.call(done / steps);
|
||||
// Tells how far [stage] got, then stops where abort was asked.
|
||||
void at(AnkiImportStage stage, int done, int total) {
|
||||
onProgress?.call(_stages.at(stage, done: done, total: total));
|
||||
abort?.check();
|
||||
}
|
||||
|
||||
// The files first: the cards refer to them.
|
||||
final assetOf = <String, String>{};
|
||||
var skippedFiles = 0;
|
||||
for (final name in media) {
|
||||
final id = await _addFile(package, name, studysetId);
|
||||
at(AnkiImportStage.files, 0, media.length);
|
||||
for (final (i, name) in media.indexed) {
|
||||
final id = await _addFile(package, name, studysetId, trail);
|
||||
if (id == null) {
|
||||
skippedFiles++;
|
||||
} else {
|
||||
assetOf[name] = id;
|
||||
}
|
||||
step();
|
||||
at(AnkiImportStage.files, i + 1, media.length);
|
||||
}
|
||||
|
||||
final top = _commonTop([for (final note in notes) note.deckPath]);
|
||||
|
|
@ -130,10 +199,15 @@ class AnkiImporter(
|
|||
return id;
|
||||
}
|
||||
final parent = await studysetFor(path.sublist(0, path.length - 1));
|
||||
return studysetOf[key] = await _studysets.create(
|
||||
final made = studysetOf[key] = await _studysets.create(
|
||||
StudysetContent.titled(path.last),
|
||||
parentId: parent,
|
||||
);
|
||||
// Those inside it go with it.
|
||||
if (parent == studysetId) {
|
||||
trail.studysets.add(made);
|
||||
}
|
||||
return made;
|
||||
}
|
||||
|
||||
final notesByStudyset = <String, List<(AnkiNote, CardContent)>>{};
|
||||
|
|
@ -151,6 +225,8 @@ class AnkiImporter(
|
|||
}
|
||||
|
||||
final cardOf = <int, String>{};
|
||||
var written = skippedNotes;
|
||||
at(AnkiImportStage.cards, written, notes.length);
|
||||
for (final MapEntry(key: studyset, value: entries)
|
||||
in notesByStudyset.entries) {
|
||||
for (var start = 0; start < entries.length; start += _batch) {
|
||||
|
|
@ -164,10 +240,13 @@ class AnkiImporter(
|
|||
for (final (i, (note, _)) in batch.indexed) {
|
||||
cardOf[note.id] = ids[i];
|
||||
}
|
||||
step(batch.length);
|
||||
if (studyset == studysetId) {
|
||||
trail.cards.addAll(ids);
|
||||
}
|
||||
written += batch.length;
|
||||
at(AnkiImportStage.cards, written, notes.length);
|
||||
}
|
||||
}
|
||||
step(skippedNotes);
|
||||
|
||||
// Tags: the note's own, and in tag mode the decks it came from.
|
||||
final tagIdOf = <String, String>{};
|
||||
|
|
@ -189,7 +268,7 @@ class AnkiImporter(
|
|||
}
|
||||
}
|
||||
await _tags.addTags(tagsByCard);
|
||||
step();
|
||||
at(AnkiImportStage.tags, 1, 1);
|
||||
|
||||
final reviews = keepProgress
|
||||
? [
|
||||
|
|
@ -209,7 +288,8 @@ class AnkiImporter(
|
|||
]
|
||||
: <NewReview>[];
|
||||
await _reviews.recordAll(reviews);
|
||||
step();
|
||||
// Once all of it is written, it is done.
|
||||
onProgress?.call(_stages.at(AnkiImportStage.reviews, done: 1, total: 1));
|
||||
|
||||
return (
|
||||
cards: cardOf.length,
|
||||
|
|
@ -227,6 +307,7 @@ class AnkiImporter(
|
|||
AnkiPackage package,
|
||||
String name,
|
||||
String studysetId,
|
||||
_AnkiTrail trail,
|
||||
) async {
|
||||
final mimeType = mediaTypeOf(name);
|
||||
final bytes = mimeType == null ? null : package.mediaBytes(name);
|
||||
|
|
@ -237,12 +318,20 @@ class AnkiImporter(
|
|||
return null;
|
||||
}
|
||||
try {
|
||||
return await _assets.add(
|
||||
final had = await _assets.idOf(
|
||||
sha256.convert(bytes).toString(),
|
||||
studysetId: studysetId,
|
||||
);
|
||||
final id = await _assets.add(
|
||||
bytes,
|
||||
fileName: name,
|
||||
mimeType: mimeType,
|
||||
studysetId: studysetId,
|
||||
);
|
||||
if (had == null && !trail.files.contains(id)) {
|
||||
trail.files.add(id);
|
||||
}
|
||||
return id;
|
||||
} on Exception catch (error) {
|
||||
// The device couldn't keep it: the cards go without it.
|
||||
log('Left out the file $name', name: 'Anki', error: error);
|
||||
|
|
@ -251,6 +340,16 @@ class AnkiImporter(
|
|||
}
|
||||
}
|
||||
|
||||
/// What an import wrote so far, for taking it back where it is aborted:
|
||||
/// the studysets made right inside the one imported into (those deeper
|
||||
/// go with them), the cards written into that one itself, and the files'
|
||||
/// rows made.
|
||||
final class _AnkiTrail() {
|
||||
final studysets = <String>[];
|
||||
final cards = <String>[];
|
||||
final files = <String>[];
|
||||
}
|
||||
|
||||
/// How many cards are written at once.
|
||||
const _batch = 200;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/work_progress_view.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/work/work_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/anki/bloc/anki_import_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_importer.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package.dart';
|
||||
|
|
@ -11,7 +13,8 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// Lets the person pick an Anki package (`.apkg`, or a whole collection's
|
||||
/// `.colpkg`) and imports it into the studyset [studysetId], or without one
|
||||
/// into a new studyset of its own named after its top deck, as they choose
|
||||
/// in a sheet; then says what came over. Completes with how its decks became
|
||||
/// in a sheet, its steps shown as it goes, with Abort, which deletes what
|
||||
/// came (decided 9 Nov 2026); then says what came over. Completes with how its decks became
|
||||
/// studysets, or with null when nothing was imported.
|
||||
Future<AnkiImportMode?> importFromAnki(
|
||||
BuildContext context, {
|
||||
|
|
@ -26,7 +29,7 @@ Future<AnkiImportMode?> importFromAnki(
|
|||
if (file == null || !context.mounted) {
|
||||
return null;
|
||||
}
|
||||
final done = await showAdaptiveSheet<AnkiImportStateDone>(
|
||||
final ended = await showAdaptiveSheet<AnkiImportState>(
|
||||
context,
|
||||
// Swiping away would leave an import half done, without asking.
|
||||
swipeable: false,
|
||||
|
|
@ -41,6 +44,7 @@ Future<AnkiImportMode?> importFromAnki(
|
|||
person.tags,
|
||||
person.reviews,
|
||||
person.assets,
|
||||
person.deletions,
|
||||
),
|
||||
person.assets,
|
||||
studysetId,
|
||||
|
|
@ -49,9 +53,13 @@ Future<AnkiImportMode?> importFromAnki(
|
|||
);
|
||||
},
|
||||
);
|
||||
if (done == null) {
|
||||
if (ended is AnkiImportStateAborted) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(l10n.importAborted)));
|
||||
}
|
||||
if (ended is! AnkiImportStateDone) {
|
||||
return null;
|
||||
}
|
||||
final done = ended;
|
||||
final result = done.result;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
|
|
@ -80,7 +88,8 @@ class const _AnkiImport() extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return BlocConsumer<AnkiImportBloc, AnkiImportState>(
|
||||
listenWhen: (previous, current) => current is AnkiImportStateDone,
|
||||
listenWhen: (previous, current) =>
|
||||
current is AnkiImportStateDone || current is AnkiImportStateAborted,
|
||||
listener: (context, state) => Navigator.of(context).pop(state),
|
||||
builder: (context, state) {
|
||||
final running = state is AnkiImportStateRunning;
|
||||
|
|
@ -98,7 +107,7 @@ class const _AnkiImport() extends StatelessWidget {
|
|||
: l10n.ankiImportTitle,
|
||||
// Closing would leave half the deck imported.
|
||||
busy: running,
|
||||
footer: summary == null
|
||||
footer: summary == null || running
|
||||
? null
|
||||
: FilledButton(
|
||||
onPressed: running
|
||||
|
|
@ -109,7 +118,9 @@ class const _AnkiImport() extends StatelessWidget {
|
|||
child: Text(l10n.ankiImportConfirm),
|
||||
),
|
||||
body: switch (state) {
|
||||
AnkiImportStateReading() || AnkiImportStateDone() => Padding(
|
||||
AnkiImportStateReading() ||
|
||||
AnkiImportStateDone() ||
|
||||
AnkiImportStateAborted() => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
|
|
@ -131,7 +142,7 @@ class const _AnkiImport() extends StatelessWidget {
|
|||
_ => _Options(
|
||||
summary: summary!,
|
||||
options: options!,
|
||||
progress: state is AnkiImportStateRunning ? state.progress : null,
|
||||
running: state is AnkiImportStateRunning ? state : null,
|
||||
),
|
||||
},
|
||||
);
|
||||
|
|
@ -145,13 +156,13 @@ class const _AnkiImport() extends StatelessWidget {
|
|||
class const _Options({
|
||||
required final AnkiImportSummary summary,
|
||||
required final AnkiImportOptions options,
|
||||
required final double? progress,
|
||||
required final AnkiImportStateRunning? running,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final running = progress != null;
|
||||
final running = this.running != null;
|
||||
void change(AnkiImportOptions options) {
|
||||
if (!running) {
|
||||
context.read<AnkiImportBloc>().add(
|
||||
|
|
@ -231,15 +242,30 @@ class const _Options({
|
|||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
if (progress case final progress?) ...[
|
||||
if (this.running case AnkiImportStateRunning(
|
||||
:final progress,
|
||||
:final aborting,
|
||||
)) ...[
|
||||
const SizedBox(height: 12),
|
||||
LinearProgressIndicator(value: progress),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
l10n.ankiImportRunning,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
WorkProgressView(
|
||||
progress:
|
||||
progress ??
|
||||
WorkProgress(
|
||||
stage: AnkiImportStage.files,
|
||||
step: 1,
|
||||
steps: AnkiImportStage.values.length,
|
||||
fraction: 0,
|
||||
),
|
||||
stageName: (stage) => switch (stage) {
|
||||
AnkiImportStage.files => l10n.workStageFiles,
|
||||
AnkiImportStage.cards => l10n.workStageCards,
|
||||
AnkiImportStage.tags => l10n.workStageTags,
|
||||
AnkiImportStage.reviews => l10n.workStageReviews,
|
||||
},
|
||||
onAbort: () => context.read<AnkiImportBloc>().add(
|
||||
const AnkiImportEventAborted(),
|
||||
),
|
||||
aborting: aborting,
|
||||
),
|
||||
],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,14 +1,21 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_editor/halkyon_editor.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/work/work_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_importer.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
|
|
@ -17,6 +24,9 @@ import '../../../support/fake_asset_gateway.dart';
|
|||
import '../../../support/fake_asset_sealing.dart';
|
||||
import '../../../support/fake_asset_server_gateway.dart';
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_learning_path_gateway.dart';
|
||||
import '../../../support/fake_note_gateway.dart';
|
||||
import '../../../support/fake_pdf_source_gateway.dart';
|
||||
import '../../../support/fake_review_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/fake_tag_gateway.dart';
|
||||
|
|
@ -49,7 +59,20 @@ void main() {
|
|||
MemoryAssetFileCache(),
|
||||
FakeAssetSealing(),
|
||||
);
|
||||
importer = AnkiImporter(studysets, cards, tags, reviews, assets);
|
||||
importer = AnkiImporter(
|
||||
studysets,
|
||||
cards,
|
||||
tags,
|
||||
reviews,
|
||||
assets,
|
||||
StudysetDeletions(
|
||||
studysets,
|
||||
cards,
|
||||
NoteRepository(FakeNoteGateway()),
|
||||
LearningPathRepository(FakeLearningPathGateway()),
|
||||
PdfSourceRepository(FakePdfSourceGateway(), assets),
|
||||
),
|
||||
);
|
||||
japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
});
|
||||
tearDown(() async {
|
||||
|
|
@ -253,8 +276,8 @@ void main() {
|
|||
expect(namesOf(list), {'Animals'});
|
||||
});
|
||||
|
||||
test('tells how far it got', () async {
|
||||
final progress = <double>[];
|
||||
test('tells how far it got, step by step (decided 9 Nov 2026)', () async {
|
||||
final progress = <WorkProgress<AnkiImportStage>>[];
|
||||
|
||||
await importer.import(
|
||||
package,
|
||||
|
|
@ -265,8 +288,98 @@ void main() {
|
|||
onProgress: progress.add,
|
||||
);
|
||||
|
||||
expect(progress, isNotEmpty);
|
||||
expect(progress.last, 1);
|
||||
expect(progress, orderedEquals([...progress]..sort()));
|
||||
expect(
|
||||
{for (final each in progress) each.stage}.toList(),
|
||||
AnkiImportStage.values,
|
||||
);
|
||||
expect(progress.last.percent, 100);
|
||||
final fractions = [for (final each in progress) each.fraction];
|
||||
expect(fractions, orderedEquals([...fractions]..sort()));
|
||||
});
|
||||
|
||||
group('aborted (decided 9 Nov 2026)', () {
|
||||
/// Imports as [import] does, aborting once [when] holds.
|
||||
Future<void> abortedImport(
|
||||
bool Function(WorkProgress<AnkiImportStage> progress) when, {
|
||||
required String studysetId,
|
||||
AnkiImportMode mode = AnkiImportMode.flatten,
|
||||
bool madeForIt = false,
|
||||
}) async {
|
||||
final abort = AbortSignal();
|
||||
await expectLater(
|
||||
importer.import(
|
||||
package,
|
||||
studysetId: studysetId,
|
||||
mode: mode,
|
||||
keepProgress: true,
|
||||
includeMedia: true,
|
||||
abort: abort,
|
||||
madeForIt: madeForIt,
|
||||
onProgress: (progress) {
|
||||
if (when(progress)) {
|
||||
abort.abort();
|
||||
}
|
||||
},
|
||||
),
|
||||
throwsA(isA<WorkAborted>()),
|
||||
);
|
||||
}
|
||||
|
||||
test('deletes the cards, the studysets made for decks and the files it '
|
||||
'added, keeping what the studyset held before', () async {
|
||||
final own = await cards.create(
|
||||
japanese,
|
||||
CardContent(
|
||||
front: RichContent.plain('mine'),
|
||||
back: RichContent.plain('mine'),
|
||||
),
|
||||
);
|
||||
|
||||
await abortedImport(
|
||||
(progress) =>
|
||||
progress.stage == AnkiImportStage.cards && progress.done > 0,
|
||||
studysetId: japanese,
|
||||
mode: AnkiImportMode.subStudysets,
|
||||
);
|
||||
|
||||
expect(
|
||||
[for (final s in await studysets.withDeleted()) s.id],
|
||||
[
|
||||
japanese,
|
||||
],
|
||||
);
|
||||
expect([for (final card in await cards.allCards()) card.id], [own]);
|
||||
expect(await assetRows.all(), isEmpty);
|
||||
});
|
||||
|
||||
test('in its last step too', () async {
|
||||
await abortedImport(
|
||||
(progress) => progress.stage == AnkiImportStage.tags,
|
||||
studysetId: japanese,
|
||||
);
|
||||
|
||||
expect(await cards.allCards(), isEmpty);
|
||||
});
|
||||
|
||||
test('a studyset made for it goes for good with it', () async {
|
||||
final made = await importer.newStudysetFor(
|
||||
package,
|
||||
fallbackTitle: 'x',
|
||||
);
|
||||
|
||||
await abortedImport(
|
||||
(progress) => progress.stage == AnkiImportStage.files,
|
||||
studysetId: made,
|
||||
madeForIt: true,
|
||||
);
|
||||
|
||||
expect(
|
||||
[for (final s in await studysets.withDeleted()) s.id],
|
||||
[
|
||||
japanese,
|
||||
],
|
||||
);
|
||||
expect(await cards.allCards(), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_editor/halkyon_editor.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/work_progress_view.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
|
||||
|
|
@ -118,6 +120,30 @@ void main() {
|
|||
expect(await cards.cardsOf('japanese'), hasLength(4));
|
||||
});
|
||||
|
||||
testWidgets('shows its steps under way, and Abort deletes what came '
|
||||
'(decided 9 Nov 2026)', (tester) async {
|
||||
final hold = cardGateway.holdWrites = Completer<void>();
|
||||
await startImport(tester, fixture('anki_latest.apkg'));
|
||||
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Import'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.text('Step 2 of 4: Writing flashcards'), findsOneWidget);
|
||||
await tester.tap(find.byKey(WorkProgressView.abortKey));
|
||||
await tester.pump();
|
||||
expect(find.text('Aborting…'), findsOneWidget);
|
||||
hold.complete();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('Import aborted: nothing of it was kept.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
final cards = CardRepository(cardGateway);
|
||||
addTearDown(cards.dispose);
|
||||
expect(await cards.cardsOf('japanese'), isEmpty);
|
||||
});
|
||||
|
||||
testWidgets("the Library's Import makes a deck a studyset of its own, "
|
||||
'named after its top deck, its subdecks inside as chosen', (
|
||||
tester,
|
||||
|
|
|
|||
Loading…
Reference in a new issue