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:
Tizian 「ティツィアーン」 2026-11-10 16:17:00 +01:00
commit 7cba684513
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
10 changed files with 345 additions and 60 deletions

View file

@ -1329,7 +1329,6 @@
"ankiImportMedia": "{count, plural, =1{Medien übernehmen (1 Datei)} other{Medien übernehmen ({count} Dateien)}}", "ankiImportMedia": "{count, plural, =1{Medien übernehmen (1 Datei)} other{Medien übernehmen ({count} Dateien)}}",
"ankiImportMediaSubtitle": "Bilder und Töne des Stapels auf den Karten behalten.", "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.", "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", "ankiImportConfirm": "Importieren",
"ankiImportNotAPackage": "Das ist kein Anki-Stapel. Exportiere in Anki einen Stapel als .apkg-Datei und importiere diese.", "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“.", "ankiImportNewerFormat": "Dieser Stapel stammt aus einer neueren Anki-Version, die diese App noch nicht kennt. Exportiere ihn erneut mit „Ältere Anki-Versionen unterstützen“.",

View file

@ -1561,7 +1561,6 @@
"left": {"type": "String"} "left": {"type": "String"}
} }
}, },
"ankiImportRunning": "Importing…",
"ankiImportConfirm": "Import", "ankiImportConfirm": "Import",
"ankiImportNotAPackage": "This isn't an Anki deck. In Anki, export a deck as an .apkg file and import that.", "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”.", "ankiImportNewerFormat": "This deck comes from a newer Anki than this app knows. Export it again with “Support older Anki versions”.",

View file

@ -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.'** /// **'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); 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. /// No description provided for @ankiImportConfirm.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -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.'; 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 @override
String get ankiImportConfirm => 'Importieren'; String get ankiImportConfirm => 'Importieren';

View file

@ -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.'; 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 @override
String get ankiImportConfirm => 'Import'; String get ankiImportConfirm => 'Import';

View file

@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.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/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_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.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.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, /// Importing one Anki package into the studyset [_studysetId]: reading it,
/// choosing how, and bringing it in. Without a studyset it goes into a new /// 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 /// 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( class AnkiImportBloc(
final AnkiPackageReader _reader, final AnkiPackageReader _reader,
final AnkiImporter _importer, final AnkiImporter _importer,
@ -26,8 +28,24 @@ class AnkiImportBloc(
} }
}); });
on<AnkiImportEventConfirmed>(_onConfirmed); 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; AnkiPackage? _package;
/// The name of the package's file, without its extension. /// The name of the package's file, without its extension.
@ -86,7 +104,7 @@ class AnkiImportBloc(
final package = _package; final package = _package;
if (state case final AnkiImportStateReady ready when package != null) { if (state case final AnkiImportStateReady ready when package != null) {
final options = ready.options; final options = ready.options;
emit(AnkiImportStateRunning(ready.summary, options, 0)); emit(AnkiImportStateRunning(ready.summary, options, null));
String? made; String? made;
try { try {
final result = await _importer.import( final result = await _importer.import(
@ -101,10 +119,19 @@ class AnkiImportBloc(
keepProgress: options.keepProgress && ready.summary.hasReviews, keepProgress: options.keepProgress && ready.summary.hasReviews,
includeMedia: options.includeMedia, includeMedia: options.includeMedia,
onProgress: (progress) => emit( 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)); emit(AnkiImportStateDone(result, options.mode));
} on WorkAborted {
emit(const AnkiImportStateAborted());
} on Exception catch (error) { } on Exception catch (error) {
log('Importing an Anki package failed', name: 'Anki', error: error); log('Importing an Anki package failed', name: 'Anki', error: error);
if (made != null) { if (made != null) {
@ -217,6 +244,9 @@ final class const AnkiImportEventOptionsChanged(
/// Bring the package in. /// Bring the package in.
final class const AnkiImportEventConfirmed() extends AnkiImportEvent; 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. /// How far an import is.
sealed class const AnkiImportState() with Equatable { sealed class const AnkiImportState() with Equatable {
@override @override
@ -248,7 +278,8 @@ final class const AnkiImportStateReady(
List<Object?> get props => [summary, options]; 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( final class const AnkiImportStateRunning(
/// What it holds. /// What it holds.
final AnkiImportSummary summary, final AnkiImportSummary summary,
@ -256,13 +287,17 @@ final class const AnkiImportStateRunning(
/// How it is imported. /// How it is imported.
final AnkiImportOptions options, final AnkiImportOptions options,
/// How far it got. /// How far it got; none told yet as it starts.
final double progress, final WorkProgress<AnkiImportStage>? progress, {
) extends AnkiImportState { final bool aborting = false,
}) extends AnkiImportState {
@override @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. /// Imported.
final class const AnkiImportStateDone( final class const AnkiImportStateDone(
/// What it brought. /// What it brought.

View file

@ -1,8 +1,10 @@
import 'dart:developer'; import 'dart:developer';
import 'package:crypto/crypto.dart';
import 'package:halkyon_editor/halkyon_editor.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/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/media_type.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_field.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package.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'; 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/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.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/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/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
@ -29,6 +32,14 @@ enum AnkiImportMode() {
tagBySubdeck, 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 /// 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 /// one side), the reviews brought over, and the files added and left out
/// (a kind the app doesn't show, missing or too large). /// (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 /// 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 /// deletions hidden on the front), its tags, its files, and, if wanted, its
/// review history, which the app's FSRS replays like its own. /// 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( class AnkiImporter(
final StudysetRepository _studysets, final StudysetRepository _studysets,
final CardRepository _cards, final CardRepository _cards,
final TagRepository _tags, final TagRepository _tags,
final ReviewRepository _reviews, final ReviewRepository _reviews,
final AssetRepository _assets, 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], /// 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] /// named after the deck all its notes are in or below, or [fallbackTitle]
/// for a package without one; returns its id, to import into. /// 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 /// Imports [package] into the studyset [studysetId] as [mode] says, its
/// review history if [keepProgress], its files if [includeMedia]; /// 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( Future<AnkiImportResult> import(
AnkiPackage package, { AnkiPackage package, {
required String studysetId, required String studysetId,
required AnkiImportMode mode, required AnkiImportMode mode,
required bool keepProgress, required bool keepProgress,
required bool includeMedia, 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 { }) async {
final notes = package.collection.notes; final notes = package.collection.notes;
final media = includeMedia final media = includeMedia
? referencedMedia(package.collection) ? referencedMedia(package.collection)
: <String>[]; : <String>[];
final steps = media.length + notes.length + 2; // Tells how far [stage] got, then stops where abort was asked.
var done = 0; void at(AnkiImportStage stage, int done, int total) {
void step([int count = 1]) { onProgress?.call(_stages.at(stage, done: done, total: total));
done += count; abort?.check();
onProgress?.call(done / steps);
} }
// The files first: the cards refer to them. // The files first: the cards refer to them.
final assetOf = <String, String>{}; final assetOf = <String, String>{};
var skippedFiles = 0; var skippedFiles = 0;
for (final name in media) { at(AnkiImportStage.files, 0, media.length);
final id = await _addFile(package, name, studysetId); for (final (i, name) in media.indexed) {
final id = await _addFile(package, name, studysetId, trail);
if (id == null) { if (id == null) {
skippedFiles++; skippedFiles++;
} else { } else {
assetOf[name] = id; assetOf[name] = id;
} }
step(); at(AnkiImportStage.files, i + 1, media.length);
} }
final top = _commonTop([for (final note in notes) note.deckPath]); final top = _commonTop([for (final note in notes) note.deckPath]);
@ -130,10 +199,15 @@ class AnkiImporter(
return id; return id;
} }
final parent = await studysetFor(path.sublist(0, path.length - 1)); 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), StudysetContent.titled(path.last),
parentId: parent, parentId: parent,
); );
// Those inside it go with it.
if (parent == studysetId) {
trail.studysets.add(made);
}
return made;
} }
final notesByStudyset = <String, List<(AnkiNote, CardContent)>>{}; final notesByStudyset = <String, List<(AnkiNote, CardContent)>>{};
@ -151,6 +225,8 @@ class AnkiImporter(
} }
final cardOf = <int, String>{}; final cardOf = <int, String>{};
var written = skippedNotes;
at(AnkiImportStage.cards, written, notes.length);
for (final MapEntry(key: studyset, value: entries) for (final MapEntry(key: studyset, value: entries)
in notesByStudyset.entries) { in notesByStudyset.entries) {
for (var start = 0; start < entries.length; start += _batch) { for (var start = 0; start < entries.length; start += _batch) {
@ -164,10 +240,13 @@ class AnkiImporter(
for (final (i, (note, _)) in batch.indexed) { for (final (i, (note, _)) in batch.indexed) {
cardOf[note.id] = ids[i]; 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. // Tags: the note's own, and in tag mode the decks it came from.
final tagIdOf = <String, String>{}; final tagIdOf = <String, String>{};
@ -189,7 +268,7 @@ class AnkiImporter(
} }
} }
await _tags.addTags(tagsByCard); await _tags.addTags(tagsByCard);
step(); at(AnkiImportStage.tags, 1, 1);
final reviews = keepProgress final reviews = keepProgress
? [ ? [
@ -209,7 +288,8 @@ class AnkiImporter(
] ]
: <NewReview>[]; : <NewReview>[];
await _reviews.recordAll(reviews); 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 ( return (
cards: cardOf.length, cards: cardOf.length,
@ -227,6 +307,7 @@ class AnkiImporter(
AnkiPackage package, AnkiPackage package,
String name, String name,
String studysetId, String studysetId,
_AnkiTrail trail,
) async { ) async {
final mimeType = mediaTypeOf(name); final mimeType = mediaTypeOf(name);
final bytes = mimeType == null ? null : package.mediaBytes(name); final bytes = mimeType == null ? null : package.mediaBytes(name);
@ -237,12 +318,20 @@ class AnkiImporter(
return null; return null;
} }
try { try {
return await _assets.add( final had = await _assets.idOf(
sha256.convert(bytes).toString(),
studysetId: studysetId,
);
final id = await _assets.add(
bytes, bytes,
fileName: name, fileName: name,
mimeType: mimeType, mimeType: mimeType,
studysetId: studysetId, studysetId: studysetId,
); );
if (had == null && !trail.files.contains(id)) {
trail.files.add(id);
}
return id;
} on Exception catch (error) { } on Exception catch (error) {
// The device couldn't keep it: the cards go without it. // The device couldn't keep it: the cards go without it.
log('Left out the file $name', name: 'Anki', error: error); 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. /// How many cards are written at once.
const _batch = 200; const _batch = 200;

View file

@ -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/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.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/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/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_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.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 /// Lets the person pick an Anki package (`.apkg`, or a whole collection's
/// `.colpkg`) and imports it into the studyset [studysetId], or without one /// `.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 /// 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. /// studysets, or with null when nothing was imported.
Future<AnkiImportMode?> importFromAnki( Future<AnkiImportMode?> importFromAnki(
BuildContext context, { BuildContext context, {
@ -26,7 +29,7 @@ Future<AnkiImportMode?> importFromAnki(
if (file == null || !context.mounted) { if (file == null || !context.mounted) {
return null; return null;
} }
final done = await showAdaptiveSheet<AnkiImportStateDone>( final ended = await showAdaptiveSheet<AnkiImportState>(
context, context,
// Swiping away would leave an import half done, without asking. // Swiping away would leave an import half done, without asking.
swipeable: false, swipeable: false,
@ -41,6 +44,7 @@ Future<AnkiImportMode?> importFromAnki(
person.tags, person.tags,
person.reviews, person.reviews,
person.assets, person.assets,
person.deletions,
), ),
person.assets, person.assets,
studysetId, 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; return null;
} }
final done = ended;
final result = done.result; final result = done.result;
messenger.showSnackBar( messenger.showSnackBar(
SnackBar( SnackBar(
@ -80,7 +88,8 @@ class const _AnkiImport() extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
return BlocConsumer<AnkiImportBloc, AnkiImportState>( 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), listener: (context, state) => Navigator.of(context).pop(state),
builder: (context, state) { builder: (context, state) {
final running = state is AnkiImportStateRunning; final running = state is AnkiImportStateRunning;
@ -98,7 +107,7 @@ class const _AnkiImport() extends StatelessWidget {
: l10n.ankiImportTitle, : l10n.ankiImportTitle,
// Closing would leave half the deck imported. // Closing would leave half the deck imported.
busy: running, busy: running,
footer: summary == null footer: summary == null || running
? null ? null
: FilledButton( : FilledButton(
onPressed: running onPressed: running
@ -109,7 +118,9 @@ class const _AnkiImport() extends StatelessWidget {
child: Text(l10n.ankiImportConfirm), child: Text(l10n.ankiImportConfirm),
), ),
body: switch (state) { body: switch (state) {
AnkiImportStateReading() || AnkiImportStateDone() => Padding( AnkiImportStateReading() ||
AnkiImportStateDone() ||
AnkiImportStateAborted() => Padding(
padding: const EdgeInsets.symmetric(vertical: 32), padding: const EdgeInsets.symmetric(vertical: 32),
child: Column( child: Column(
children: [ children: [
@ -131,7 +142,7 @@ class const _AnkiImport() extends StatelessWidget {
_ => _Options( _ => _Options(
summary: summary!, summary: summary!,
options: options!, 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({ class const _Options({
required final AnkiImportSummary summary, required final AnkiImportSummary summary,
required final AnkiImportOptions options, required final AnkiImportOptions options,
required final double? progress, required final AnkiImportStateRunning? running,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
final running = progress != null; final running = this.running != null;
void change(AnkiImportOptions options) { void change(AnkiImportOptions options) {
if (!running) { if (!running) {
context.read<AnkiImportBloc>().add( context.read<AnkiImportBloc>().add(
@ -231,15 +242,30 @@ class const _Options({
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading, controlAffinity: ListTileControlAffinity.leading,
), ),
if (progress case final progress?) ...[ if (this.running case AnkiImportStateRunning(
:final progress,
:final aborting,
)) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
LinearProgressIndicator(value: progress), WorkProgressView(
const SizedBox(height: 6), progress:
Text( progress ??
l10n.ankiImportRunning, WorkProgress(
style: theme.textTheme.bodySmall?.copyWith( stage: AnkiImportStage.files,
color: theme.colorScheme.onSurfaceVariant, 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,
), ),
], ],
], ],

View file

@ -1,14 +1,21 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; 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/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_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.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.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.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/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.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/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/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.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_sealing.dart';
import '../../../support/fake_asset_server_gateway.dart'; import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/fake_card_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_review_gateway.dart';
import '../../../support/fake_studyset_gateway.dart'; import '../../../support/fake_studyset_gateway.dart';
import '../../../support/fake_tag_gateway.dart'; import '../../../support/fake_tag_gateway.dart';
@ -49,7 +59,20 @@ void main() {
MemoryAssetFileCache(), MemoryAssetFileCache(),
FakeAssetSealing(), 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')); japanese = await studysets.create(StudysetContent.titled('Japanese'));
}); });
tearDown(() async { tearDown(() async {
@ -253,8 +276,8 @@ void main() {
expect(namesOf(list), {'Animals'}); expect(namesOf(list), {'Animals'});
}); });
test('tells how far it got', () async { test('tells how far it got, step by step (decided 9 Nov 2026)', () async {
final progress = <double>[]; final progress = <WorkProgress<AnkiImportStage>>[];
await importer.import( await importer.import(
package, package,
@ -265,8 +288,98 @@ void main() {
onProgress: progress.add, onProgress: progress.add,
); );
expect(progress, isNotEmpty); expect(
expect(progress.last, 1); {for (final each in progress) each.stage}.toList(),
expect(progress, orderedEquals([...progress]..sort())); 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);
});
}); });
} }

View file

@ -1,9 +1,11 @@
import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/testing.dart'; import 'package:halkyon_app/testing.dart';
import 'package:halkyon_editor/halkyon_editor.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/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/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/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.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)); 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, " testWidgets("the Library's Import makes a deck a studyset of its own, "
'named after its top deck, its subdecks inside as chosen', ( 'named after its top deck, its subdecks inside as chosen', (
tester, tester,