feat(learn): exporting shows its steps, counts and percent, with Abort

Exporting a studyset said "Exporting…" in a snack bar until the place to
keep it was asked. It now shows a sheet with its steps (reading the
content, gathering files for an archive, packing), how many of the step's
items are done and the percent of all of it, and Abort, which keeps
nothing (decided 9 Nov 2026); then asks where to keep the file as before.

Both exporters check between studysets, and the archive's between files;
packing runs on its isolate to its end, its result dropped once aborted.
ExportCubit runs an export's work behind the sheet (runExport), which
says how it ended: made, failed (a file missing named as before) or
aborted.

Whats-New: Exporting shows its steps and how far it has come, and Abort stops it.
Whats-New-De: Der Export zeigt seine Schritte und wie weit er ist, und „Abbrechen“ beendet ihn.

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:07 +01:00
commit 99b75645ad
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
14 changed files with 404 additions and 94 deletions

View file

@ -297,7 +297,6 @@
"studysetSpacedRepetitionHint": "Wiederhole die fälligen Karten, geplant durch FSRS.",
"studysetImportContent": "Inhalt importieren",
"studysetMenuExport": "Inhalt exportieren",
"exportRunning": "Wird exportiert …",
"exportedMarkdown": "Als Markdown exportiert.",
"exportedZip": "{count, plural, =1{Als Markdown mit 1 Datei exportiert (.zip).} other{Als Markdown mit {count} Dateien exportiert (.zip).}}",
"exportMissingFiles": "{count, plural, =1{1 Datei war nicht auf diesem Gerät und fehlt.} other{{count} Dateien waren nicht auf diesem Gerät und fehlen.}}",

View file

@ -351,7 +351,6 @@
"studysetSpacedRepetitionHint": "Review the cards that are due, scheduled by FSRS.",
"studysetImportContent": "Import content",
"studysetMenuExport": "Export content",
"exportRunning": "Exporting…",
"exportedMarkdown": "Exported as Markdown.",
"exportedZip": "{count, plural, =1{Exported as Markdown with 1 file (.zip).} other{Exported as Markdown with {count} files (.zip).}}",
"@exportedZip": {"placeholders": {"count": {"type": "int"}}},

View file

@ -1822,12 +1822,6 @@ abstract class AppLocalizations {
/// **'Export content'**
String get studysetMenuExport;
/// No description provided for @exportRunning.
///
/// In en, this message translates to:
/// **'Exporting…'**
String get exportRunning;
/// No description provided for @exportedMarkdown.
///
/// In en, this message translates to:

View file

@ -1111,9 +1111,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get studysetMenuExport => 'Inhalt exportieren';
@override
String get exportRunning => 'Wird exportiert …';
@override
String get exportedMarkdown => 'Als Markdown exportiert.';

View file

@ -1101,9 +1101,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studysetMenuExport => 'Export content';
@override
String get exportRunning => 'Exporting…';
@override
String get exportedMarkdown => 'Exported as Markdown.';

View file

@ -5,11 +5,13 @@ import 'package:crypto/crypto.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_payload.dart';
import 'package:halkyon_learn_flutter/src/core/asset/model/stored_file.dart';
import 'package:halkyon_learn_flutter/src/core/work/work_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/model/import_archive.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/export/data/studyset_markdown.dart'
show ExportFile;
import 'package:halkyon_learn_flutter/src/feature/export/model/export_stage.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_step_payload.dart';
@ -71,9 +73,14 @@ class ArchiveExporter(
/// [withProgress] as the class says. Throws [ArchiveFilesMissing] when a
/// file the content names can't be had now, rather than leaving it out of
/// an archive that would look whole.
///
/// Tells [onProgress] how far it got, studyset by studyset and file by
/// file; once [abort] is asked, stops between them with [WorkAborted].
Future<List<ExportFile>> export(
String studysetId, {
required bool withProgress,
ExportProgress? onProgress,
AbortSignal? abort,
}) async {
final all = await _studysets.all();
final root = all.where((each) => each.id == studysetId).firstOrNull;
@ -114,7 +121,9 @@ class ArchiveExporter(
final steps = <String, Map<String, Object?>>{};
final pdfs = <String, Map<String, Object?>>{};
final cardIds = <String>{};
for (final studyset in studysets) {
for (final (read, studyset) in studysets.indexed) {
onProgress?.call(ExportStage.reading, read, studysets.length);
abort?.check();
final directory = directories[studyset.id]!;
items['${directory}studyset.json'] = {
'id': studyset.id,
@ -211,7 +220,9 @@ class ArchiveExporter(
final index = <String, Object?>{};
final contents = <String, Uint8List>{};
var missing = 0;
for (final id in assetIds) {
for (final (gathered, id) in assetIds.indexed) {
onProgress?.call(ExportStage.gathering, gathered, assetIds.length);
abort?.check();
final file = await _fileOf(id);
if (file == null) {
missing++;
@ -229,6 +240,7 @@ class ArchiveExporter(
if (missing > 0) {
throw ArchiveFilesMissing(missing);
}
onProgress?.call(ExportStage.gathering, assetIds.length, assetIds.length);
return [
_file('archive.json', {

View file

@ -0,0 +1,93 @@
import 'dart:typed_data';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/work/work_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/export/model/export_stage.dart';
/// A file an export made, to be kept where the person chooses, and what
/// to say once it is.
typedef ExportedFile = ({
String name,
Uint8List bytes,
String mimeType,
String message,
});
/// An export under way (decided 9 Nov 2026): runs [_work], telling its
/// progress through [stages] as it goes, until it made its file, failed,
/// or was aborted, which keeps nothing.
class ExportCubit(
final WorkStages<ExportStage> stages,
final Future<ExportedFile> Function(
ExportProgress onProgress,
AbortSignal abort,
)
_work,
) extends Cubit<ExportState> {
this : super(const ExportStateRunning());
final _abort = AbortSignal();
/// Runs the export.
Future<void> start() async {
try {
final file = await _work((stage, done, total) {
if (!isClosed) {
emit(
ExportStateRunning(
progress: stages.at(stage, done: done, total: total),
aborting: _abort.aborted,
),
);
}
}, _abort);
// Abort asked while the last of it ran: nothing is kept all the same.
_abort.check();
emit(ExportStateDone(file));
} on WorkAborted {
emit(const ExportStateAborted());
} on Object catch (error) {
emit(ExportStateFailed(error));
}
}
/// Stops the export; nothing of it is kept.
void abort() {
if (state case ExportStateRunning(:final progress, aborting: false)) {
_abort.abort();
emit(ExportStateRunning(progress: progress, aborting: true));
}
}
}
/// Where an export is.
sealed class const ExportState() with Equatable {
@override
List<Object?> get props => const [];
}
/// Under way, as far as [progress] (none told yet as it starts), or,
/// [aborting], stopping.
final class const ExportStateRunning({
final WorkProgress<ExportStage>? progress,
final bool aborting = false,
}) extends ExportState {
@override
List<Object?> get props => [progress, aborting];
}
/// Made [file].
final class const ExportStateDone(final ExportedFile file) extends ExportState {
@override
List<Object?> get props => [file];
}
/// Aborted.
final class const ExportStateAborted() extends ExportState;
/// Failed with [error].
final class const ExportStateFailed(final Object error) extends ExportState {
@override
List<Object?> get props => [error];
}

View file

@ -5,9 +5,11 @@ import 'package:halkyon_learn_flutter/src/core/asset/model/stored_file.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_markdown.dart';
import 'package:halkyon_learn_flutter/src/core/work/work_progress.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/export/model/export_stage.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart';
@ -72,10 +74,15 @@ class StudysetMarkdownExporter(
) {
/// The studyset [studysetId] and everything inside it, sources cited in
/// [style].
///
/// Tells [onProgress] how far it got, studyset by studyset; once [abort]
/// is asked, stops between them with [WorkAborted].
Future<StudysetMarkdown> export(
String studysetId, {
required StudysetExportTexts texts,
required CitationStyle style,
ExportProgress? onProgress,
AbortSignal? abort,
}) async {
final all = await _studysets.all();
final root = all.where((each) => each.id == studysetId).firstOrNull;
@ -83,7 +90,20 @@ class StudysetMarkdownExporter(
throw ArgumentError.value(studysetId, 'studysetId', 'is no studyset');
}
final export = _Export(texts, style, _fileOf);
await _studyset(export, root, all, depth: 1, seen: {});
final total = subtreeOf(studysetId, all).length;
final seen = <String>{};
await _studyset(
export,
root,
all,
depth: 1,
seen: seen,
read: () {
onProgress?.call(ExportStage.reading, seen.length, total);
abort?.check();
},
);
onProgress?.call(ExportStage.reading, total, total);
return (
markdown: '${export.blocks.join('\n\n')}\n',
files: export.files,
@ -100,7 +120,9 @@ class StudysetMarkdownExporter(
List<Studyset> all, {
required int depth,
required Set<String> seen,
required void Function() read,
}) async {
read();
if (!seen.add(studyset.id)) {
return;
}
@ -191,7 +213,14 @@ class StudysetMarkdownExporter(
}
for (final child in childrenOf(studyset.id, all)) {
await _studyset(out, child, all, depth: depth + 1, seen: seen);
await _studyset(
out,
child,
all,
depth: depth + 1,
seen: seen,
read: read,
);
}
}

View file

@ -0,0 +1,11 @@
/// The steps of an export (decided 9 Nov 2026): reading the studysets and
/// what is in them, gathering the files their content names (an archive's;
/// Markdown gathers them as it reads), and packing the zip.
enum ExportStage() {
reading,
gathering,
packing,
}
/// Tells how far an export's [stage] got: [done] of [total].
typedef ExportProgress = void Function(ExportStage stage, int done, int total);

View file

@ -0,0 +1,70 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.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/export/bloc/export_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/export/model/export_stage.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// Runs [work] in a sheet showing its steps through [stages], how far it
/// has come and Abort, nothing else closing it meanwhile (decided 9 Nov
/// 2026); completes with how it ended.
Future<ExportState> runExport(
BuildContext context, {
required WorkStages<ExportStage> stages,
required Future<ExportedFile> Function(
ExportProgress onProgress,
AbortSignal abort,
)
work,
}) async =>
await showAdaptiveSheet<ExportState>(
context,
// Swiping away would leave it running unseen.
swipeable: false,
builder: (context) => BlocProvider(
create: (context) => ExportCubit(stages, work)..start(),
child: _ExportProgress(stages: stages),
),
) ??
const ExportStateAborted();
class const _ExportProgress({
required final WorkStages<ExportStage> stages,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return BlocConsumer<ExportCubit, ExportState>(
listenWhen: (previous, current) => current is! ExportStateRunning,
listener: (context, state) => Navigator.of(context).pop(state),
builder: (context, state) {
final (progress, aborting) = switch (state) {
ExportStateRunning(:final progress, :final aborting) => (
progress,
aborting,
),
_ => (null, true),
};
return SheetScaffold(
title: l10n.exportProgressTitle,
busy: true,
body: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: WorkProgressView(
progress: progress ?? stages.at(stages.stages.first),
stageName: (stage) => switch (stage) {
ExportStage.reading => l10n.workStageReading,
ExportStage.gathering => l10n.workStageGathering,
ExportStage.packing => l10n.workStagePacking,
},
onAbort: () => context.read<ExportCubit>().abort(),
aborting: aborting,
),
),
);
},
);
}
}

View file

@ -6,8 +6,12 @@ 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/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/editor/callout_block.dart';
import 'package:halkyon_learn_flutter/src/core/work/work_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_exporter.dart';
import 'package:halkyon_learn_flutter/src/feature/export/bloc/export_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/export/data/studyset_markdown.dart';
import 'package:halkyon_learn_flutter/src/feature/export/model/export_stage.dart';
import 'package:halkyon_learn_flutter/src/feature/export/widget/export_progress_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/step_kind_labels.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -28,8 +32,9 @@ typedef ExportChoice = ({ExportFormat format, bool withProgress});
/// Exports [studyset] and everything inside it as the person chooses in a
/// sheet (decided 2026-10-15): an archive to import again, with their
/// progress or without, first; or Markdown to read elsewhere. Kept where
/// they choose; says how it went.
/// progress or without, first; or Markdown to read elsewhere. Its steps
/// show in a sheet with Abort as it is made; then it is kept where they
/// choose; says how it went.
Future<void> exportStudyset(BuildContext context, Studyset studyset) async {
final choice = await showAdaptiveSheet<ExportChoice>(
context,
@ -51,16 +56,15 @@ Future<void> exportStudyset(BuildContext context, Studyset studyset) async {
}
/// Exports [studyset] as an archive, as a `.zip` named after it; with
/// [withProgress], the person's progress in it too.
/// [withProgress], the person's progress in it too. Its steps show in a
/// sheet with Abort (decided 9 Nov 2026).
Future<void> _exportArchive(
BuildContext context,
Studyset studyset, {
required bool withProgress,
}) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final person = PersonDependencies.of(context);
final saver = Dependencies.of(context).fileSaver;
final archiver = Dependencies.of(context).exportArchiver;
final exporter = ArchiveExporter(
person.studysets,
@ -73,41 +77,37 @@ Future<void> _exportArchive(
person.reviews,
person.assets.fileOf,
);
void say(String message) => messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
say(l10n.exportRunning);
final bool saved;
try {
saved = await saver.save(
name: '${_fileStem(studyset.content.title)}.zip',
bytes: await archiver.zip(
await exporter.export(studyset.id, withProgress: withProgress),
),
mimeType: 'application/zip',
);
} on ArchiveFilesMissing catch (missing) {
say(l10n.exportArchiveFilesMissing(missing.count));
return;
} on Object catch (_) {
say(l10n.exportFailed);
return;
}
if (!saved) {
messenger.hideCurrentSnackBar();
return;
}
say(l10n.exportedArchive);
await _runAndSave(
context,
stages: WorkStages(
ExportStage.values,
weights: const {ExportStage.reading: 2, ExportStage.gathering: 2},
),
work: (onProgress, abort) async {
final files = await exporter.export(
studyset.id,
withProgress: withProgress,
onProgress: onProgress,
abort: abort,
);
onProgress(ExportStage.packing, 0, 0);
final bytes = await archiver.zip(files);
onProgress(ExportStage.packing, 1, 1);
return (
name: '${_fileStem(studyset.content.title)}.zip',
bytes: bytes,
mimeType: 'application/zip',
message: l10n.exportedArchive,
);
},
);
}
/// Exports [studyset] as Markdown: a `.md` file, or a `.zip` of it and the
/// files it links to when it has any.
/// files it links to when it has any. Its steps show in a sheet with Abort.
Future<void> _exportMarkdown(BuildContext context, Studyset studyset) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final person = PersonDependencies.of(context);
final saver = Dependencies.of(context).fileSaver;
final archiver = Dependencies.of(context).exportArchiver;
final style = context.read<DeviceSettingsBloc>().state.citationStyle;
final exporter = StudysetMarkdownExporter(
@ -118,60 +118,98 @@ Future<void> _exportMarkdown(BuildContext context, Studyset studyset) async {
person.pdfSources,
person.assets.fileOf,
);
await _runAndSave(
context,
// It gathers the files as it reads.
stages: WorkStages(
const [ExportStage.reading, ExportStage.packing],
weights: const {ExportStage.reading: 4},
),
work: (onProgress, abort) async {
final exported = await exporter.export(
studyset.id,
texts: exportTexts(l10n),
style: style,
onProgress: onProgress,
abort: abort,
);
final name = _fileStem(studyset.content.title);
final markdown = utf8.encode(exported.markdown);
final missing = [
if (exported.missingFiles > 0)
l10n.exportMissingFiles(exported.missingFiles),
];
if (exported.files.isEmpty) {
return (
name: '$name.md',
bytes: markdown,
mimeType: 'text/markdown',
message: [l10n.exportedMarkdown, ...missing].join(' '),
);
}
onProgress(ExportStage.packing, 0, 0);
final bytes = await archiver.zip([
(path: '$name.md', bytes: markdown),
...exported.files,
]);
onProgress(ExportStage.packing, 1, 1);
return (
name: '$name.zip',
bytes: bytes,
mimeType: 'application/zip',
message: [
l10n.exportedZip(exported.files.length),
...missing,
].join(' '),
);
},
);
}
/// Runs an export's [work] in its progress sheet, then keeps the file it
/// made where the person chooses; says how it went (nothing more where
/// they cancelled choosing where).
Future<void> _runAndSave(
BuildContext context, {
required WorkStages<ExportStage> stages,
required Future<ExportedFile> Function(
ExportProgress onProgress,
AbortSignal abort,
)
work,
}) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final saver = Dependencies.of(context).fileSaver;
void say(String message) => messenger
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
say(l10n.exportRunning);
final StudysetMarkdown exported;
final ended = await runExport(context, stages: stages, work: work);
if (ended is! ExportStateDone) {
say(switch (ended) {
ExportStateFailed(error: ArchiveFilesMissing(:final count)) =>
l10n.exportArchiveFilesMissing(count),
ExportStateFailed() => l10n.exportFailed,
_ => l10n.exportAborted,
});
return;
}
final file = ended.file;
final bool saved;
try {
exported = await exporter.export(
studyset.id,
texts: exportTexts(l10n),
style: style,
saved = await saver.save(
name: file.name,
bytes: file.bytes,
mimeType: file.mimeType,
);
} on Object catch (_) {
say(l10n.exportFailed);
return;
}
final name = _fileStem(studyset.content.title);
final markdown = utf8.encode(exported.markdown);
final bool saved;
try {
if (exported.files.isEmpty) {
saved = await saver.save(
name: '$name.md',
bytes: markdown,
mimeType: 'text/markdown',
);
} else {
saved = await saver.save(
name: '$name.zip',
bytes: await archiver.zip([
(path: '$name.md', bytes: markdown),
...exported.files,
]),
mimeType: 'application/zip',
);
}
} on Object catch (_) {
say(l10n.exportFailed);
return;
if (saved) {
say(file.message);
}
if (!saved) {
messenger.hideCurrentSnackBar();
return;
}
say(
[
if (exported.files.isEmpty)
l10n.exportedMarkdown
else
l10n.exportedZip(exported.files.length),
if (exported.missingFiles > 0)
l10n.exportMissingFiles(exported.missingFiles),
].join(' '),
);
}
/// The words of an export in [l10n]'s language.

View file

@ -7,6 +7,7 @@ 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/model/stored_file.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/core/work/work_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_exporter.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_importer.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_reader.dart';
@ -15,6 +16,7 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
import 'package:halkyon_learn_flutter/src/feature/export/data/export_archive.dart';
import 'package:halkyon_learn_flutter/src/feature/export/model/export_stage.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
@ -519,4 +521,37 @@ void main() {
),
);
});
test('tells its steps, studyset by studyset and file by file, and stops '
'once aborted (decided 9 Nov 2026)', () async {
final a = _Person();
final made = await _library(a);
final seen = <(ExportStage, int, int)>[];
await a.exporter().export(
made.japanese,
withProgress: true,
onProgress: (stage, done, total) => seen.add((stage, done, total)),
);
expect(
{for (final (stage, _, _) in seen) stage},
{
ExportStage.reading,
ExportStage.gathering,
},
);
final (_, done, total) = seen.last;
expect(done, total);
final abort = AbortSignal();
await expectLater(
a.exporter().export(
made.japanese,
withProgress: true,
abort: abort,
onProgress: (stage, done, total) => abort.abort(),
),
throwsA(isA<WorkAborted>()),
);
});
}

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
@ -7,8 +8,11 @@ import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_payload.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.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/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/export/data/export_archive.dart';
import 'package:halkyon_learn_flutter/src/feature/export/data/studyset_markdown.dart';
import 'package:halkyon_learn_flutter/src/feature/export/widget/export_studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
@ -34,6 +38,7 @@ void main() {
late MemoryAssetFileCache cache;
late FakeFileSaver saver;
late FakeReviewGateway reviewGateway;
late _HeldExportArchiver archiver;
setUp(() {
studysets = FakeStudysetGateway()
@ -46,6 +51,7 @@ void main() {
cache = MemoryAssetFileCache();
saver = FakeFileSaver();
reviewGateway = FakeReviewGateway();
archiver = _HeldExportArchiver();
});
Future<String> card(CardContent content) {
@ -74,6 +80,7 @@ void main() {
assetFiles: cache,
fileSaver: saver,
reviewGateway: reviewGateway,
exportArchiver: archiver,
),
location: RoutePaths.studyset('spanish'),
);
@ -297,6 +304,23 @@ void main() {
);
});
testWidgets('shows its steps under way; Abort keeps nothing (decided '
'9 Nov 2026)', (tester) async {
final hold = archiver.hold = Completer<void>();
await exportFromMenu(tester, choose: 'Archive');
expect(find.text('Step 3 of 3: Packing'), findsOneWidget);
await tester.tap(find.byKey(WorkProgressView.abortKey));
await tester.pump();
expect(find.text('Aborting…'), findsOneWidget);
hold.complete();
await tester.pumpAndSettle();
expect(saver.saved, isEmpty);
expect(find.text('Export aborted.'), findsOneWidget);
expect(find.textContaining('Exported'), findsNothing);
});
testWidgets('cancelled where to keep it, it says nothing more', (
tester,
) async {
@ -309,3 +333,14 @@ void main() {
expect(find.textContaining('Exported'), findsNothing);
});
}
/// Zips as the app does, once [hold] (where set) completes.
class _HeldExportArchiver() implements ExportArchiver {
Completer<void>? hold;
@override
Future<Uint8List> zip(List<ExportFile> files) async {
await hold?.future;
return zipExport(files);
}
}

View file

@ -143,6 +143,7 @@ Dependencies testDependencies({
SoundPlayer? sounds,
FilePicker? files,
FileSaver? fileSaver,
ExportArchiver? exportArchiver,
YoutubeVideos? videos,
PdfEngine? pdfEngine,
SettingsGateway? settingsGateway,
@ -255,7 +256,7 @@ Dependencies testDependencies({
pdfEngine: engine,
ankiPackages: InlineAnkiPackageReader(),
archives: InlineArchiveReader(),
exportArchiver: InlineExportArchiver(),
exportArchiver: exportArchiver ?? InlineExportArchiver(),
deviceSettingsStore:
deviceSettingsStore ?? MemoryDeviceSettingsStore(deviceSettings),
deviceSettings: deviceSettings,