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:
parent
7cba684513
commit
99b75645ad
14 changed files with 404 additions and 94 deletions
|
|
@ -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.}}",
|
||||
|
|
|
|||
|
|
@ -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"}}},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue