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.",
|
"studysetSpacedRepetitionHint": "Wiederhole die fälligen Karten, geplant durch FSRS.",
|
||||||
"studysetImportContent": "Inhalt importieren",
|
"studysetImportContent": "Inhalt importieren",
|
||||||
"studysetMenuExport": "Inhalt exportieren",
|
"studysetMenuExport": "Inhalt exportieren",
|
||||||
"exportRunning": "Wird exportiert …",
|
|
||||||
"exportedMarkdown": "Als Markdown exportiert.",
|
"exportedMarkdown": "Als Markdown exportiert.",
|
||||||
"exportedZip": "{count, plural, =1{Als Markdown mit 1 Datei exportiert (.zip).} other{Als Markdown mit {count} Dateien exportiert (.zip).}}",
|
"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.}}",
|
"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.",
|
"studysetSpacedRepetitionHint": "Review the cards that are due, scheduled by FSRS.",
|
||||||
"studysetImportContent": "Import content",
|
"studysetImportContent": "Import content",
|
||||||
"studysetMenuExport": "Export content",
|
"studysetMenuExport": "Export content",
|
||||||
"exportRunning": "Exporting…",
|
|
||||||
"exportedMarkdown": "Exported as Markdown.",
|
"exportedMarkdown": "Exported as Markdown.",
|
||||||
"exportedZip": "{count, plural, =1{Exported as Markdown with 1 file (.zip).} other{Exported as Markdown with {count} files (.zip).}}",
|
"exportedZip": "{count, plural, =1{Exported as Markdown with 1 file (.zip).} other{Exported as Markdown with {count} files (.zip).}}",
|
||||||
"@exportedZip": {"placeholders": {"count": {"type": "int"}}},
|
"@exportedZip": {"placeholders": {"count": {"type": "int"}}},
|
||||||
|
|
|
||||||
|
|
@ -1822,12 +1822,6 @@ abstract class AppLocalizations {
|
||||||
/// **'Export content'**
|
/// **'Export content'**
|
||||||
String get studysetMenuExport;
|
String get studysetMenuExport;
|
||||||
|
|
||||||
/// No description provided for @exportRunning.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Exporting…'**
|
|
||||||
String get exportRunning;
|
|
||||||
|
|
||||||
/// No description provided for @exportedMarkdown.
|
/// No description provided for @exportedMarkdown.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -1111,9 +1111,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get studysetMenuExport => 'Inhalt exportieren';
|
String get studysetMenuExport => 'Inhalt exportieren';
|
||||||
|
|
||||||
@override
|
|
||||||
String get exportRunning => 'Wird exportiert …';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get exportedMarkdown => 'Als Markdown exportiert.';
|
String get exportedMarkdown => 'Als Markdown exportiert.';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1101,9 +1101,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get studysetMenuExport => 'Export content';
|
String get studysetMenuExport => 'Export content';
|
||||||
|
|
||||||
@override
|
|
||||||
String get exportRunning => 'Exporting…';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get exportedMarkdown => 'Exported as Markdown.';
|
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_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_payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/asset/model/stored_file.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/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_payload.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/export/data/studyset_markdown.dart'
|
import 'package:halkyon_learn_flutter/src/feature/export/data/studyset_markdown.dart'
|
||||||
show ExportFile;
|
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_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_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_step_payload.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
|
/// [withProgress] as the class says. Throws [ArchiveFilesMissing] when a
|
||||||
/// file the content names can't be had now, rather than leaving it out of
|
/// file the content names can't be had now, rather than leaving it out of
|
||||||
/// an archive that would look whole.
|
/// 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(
|
Future<List<ExportFile>> export(
|
||||||
String studysetId, {
|
String studysetId, {
|
||||||
required bool withProgress,
|
required bool withProgress,
|
||||||
|
ExportProgress? onProgress,
|
||||||
|
AbortSignal? abort,
|
||||||
}) async {
|
}) async {
|
||||||
final all = await _studysets.all();
|
final all = await _studysets.all();
|
||||||
final root = all.where((each) => each.id == studysetId).firstOrNull;
|
final root = all.where((each) => each.id == studysetId).firstOrNull;
|
||||||
|
|
@ -114,7 +121,9 @@ class ArchiveExporter(
|
||||||
final steps = <String, Map<String, Object?>>{};
|
final steps = <String, Map<String, Object?>>{};
|
||||||
final pdfs = <String, Map<String, Object?>>{};
|
final pdfs = <String, Map<String, Object?>>{};
|
||||||
final cardIds = <String>{};
|
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]!;
|
final directory = directories[studyset.id]!;
|
||||||
items['${directory}studyset.json'] = {
|
items['${directory}studyset.json'] = {
|
||||||
'id': studyset.id,
|
'id': studyset.id,
|
||||||
|
|
@ -211,7 +220,9 @@ class ArchiveExporter(
|
||||||
final index = <String, Object?>{};
|
final index = <String, Object?>{};
|
||||||
final contents = <String, Uint8List>{};
|
final contents = <String, Uint8List>{};
|
||||||
var missing = 0;
|
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);
|
final file = await _fileOf(id);
|
||||||
if (file == null) {
|
if (file == null) {
|
||||||
missing++;
|
missing++;
|
||||||
|
|
@ -229,6 +240,7 @@ class ArchiveExporter(
|
||||||
if (missing > 0) {
|
if (missing > 0) {
|
||||||
throw ArchiveFilesMissing(missing);
|
throw ArchiveFilesMissing(missing);
|
||||||
}
|
}
|
||||||
|
onProgress?.call(ExportStage.gathering, assetIds.length, assetIds.length);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
_file('archive.json', {
|
_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/citation.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/citation/model/source.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/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/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/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/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/exercise.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.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
|
/// The studyset [studysetId] and everything inside it, sources cited in
|
||||||
/// [style].
|
/// [style].
|
||||||
|
///
|
||||||
|
/// Tells [onProgress] how far it got, studyset by studyset; once [abort]
|
||||||
|
/// is asked, stops between them with [WorkAborted].
|
||||||
Future<StudysetMarkdown> export(
|
Future<StudysetMarkdown> export(
|
||||||
String studysetId, {
|
String studysetId, {
|
||||||
required StudysetExportTexts texts,
|
required StudysetExportTexts texts,
|
||||||
required CitationStyle style,
|
required CitationStyle style,
|
||||||
|
ExportProgress? onProgress,
|
||||||
|
AbortSignal? abort,
|
||||||
}) async {
|
}) async {
|
||||||
final all = await _studysets.all();
|
final all = await _studysets.all();
|
||||||
final root = all.where((each) => each.id == studysetId).firstOrNull;
|
final root = all.where((each) => each.id == studysetId).firstOrNull;
|
||||||
|
|
@ -83,7 +90,20 @@ class StudysetMarkdownExporter(
|
||||||
throw ArgumentError.value(studysetId, 'studysetId', 'is no studyset');
|
throw ArgumentError.value(studysetId, 'studysetId', 'is no studyset');
|
||||||
}
|
}
|
||||||
final export = _Export(texts, style, _fileOf);
|
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 (
|
return (
|
||||||
markdown: '${export.blocks.join('\n\n')}\n',
|
markdown: '${export.blocks.join('\n\n')}\n',
|
||||||
files: export.files,
|
files: export.files,
|
||||||
|
|
@ -100,7 +120,9 @@ class StudysetMarkdownExporter(
|
||||||
List<Studyset> all, {
|
List<Studyset> all, {
|
||||||
required int depth,
|
required int depth,
|
||||||
required Set<String> seen,
|
required Set<String> seen,
|
||||||
|
required void Function() read,
|
||||||
}) async {
|
}) async {
|
||||||
|
read();
|
||||||
if (!seen.add(studyset.id)) {
|
if (!seen.add(studyset.id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -191,7 +213,14 @@ class StudysetMarkdownExporter(
|
||||||
}
|
}
|
||||||
|
|
||||||
for (final child in childrenOf(studyset.id, all)) {
|
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/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/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/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/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/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/learning_path/widget/step_kind_labels.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||||
import 'package:halkyon_ui/halkyon_ui.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
|
/// Exports [studyset] and everything inside it as the person chooses in a
|
||||||
/// sheet (decided 2026-10-15): an archive to import again, with their
|
/// sheet (decided 2026-10-15): an archive to import again, with their
|
||||||
/// progress or without, first; or Markdown to read elsewhere. Kept where
|
/// progress or without, first; or Markdown to read elsewhere. Its steps
|
||||||
/// they choose; says how it went.
|
/// 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 {
|
Future<void> exportStudyset(BuildContext context, Studyset studyset) async {
|
||||||
final choice = await showAdaptiveSheet<ExportChoice>(
|
final choice = await showAdaptiveSheet<ExportChoice>(
|
||||||
context,
|
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
|
/// 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(
|
Future<void> _exportArchive(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Studyset studyset, {
|
Studyset studyset, {
|
||||||
required bool withProgress,
|
required bool withProgress,
|
||||||
}) async {
|
}) async {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
|
||||||
final person = PersonDependencies.of(context);
|
final person = PersonDependencies.of(context);
|
||||||
final saver = Dependencies.of(context).fileSaver;
|
|
||||||
final archiver = Dependencies.of(context).exportArchiver;
|
final archiver = Dependencies.of(context).exportArchiver;
|
||||||
final exporter = ArchiveExporter(
|
final exporter = ArchiveExporter(
|
||||||
person.studysets,
|
person.studysets,
|
||||||
|
|
@ -73,41 +77,37 @@ Future<void> _exportArchive(
|
||||||
person.reviews,
|
person.reviews,
|
||||||
person.assets.fileOf,
|
person.assets.fileOf,
|
||||||
);
|
);
|
||||||
void say(String message) => messenger
|
await _runAndSave(
|
||||||
..hideCurrentSnackBar()
|
context,
|
||||||
..showSnackBar(SnackBar(content: Text(message)));
|
stages: WorkStages(
|
||||||
|
ExportStage.values,
|
||||||
say(l10n.exportRunning);
|
weights: const {ExportStage.reading: 2, ExportStage.gathering: 2},
|
||||||
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',
|
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,
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
} 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exports [studyset] as Markdown: a `.md` file, or a `.zip` of it and the
|
/// 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 {
|
Future<void> _exportMarkdown(BuildContext context, Studyset studyset) async {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
|
||||||
final person = PersonDependencies.of(context);
|
final person = PersonDependencies.of(context);
|
||||||
final saver = Dependencies.of(context).fileSaver;
|
|
||||||
final archiver = Dependencies.of(context).exportArchiver;
|
final archiver = Dependencies.of(context).exportArchiver;
|
||||||
final style = context.read<DeviceSettingsBloc>().state.citationStyle;
|
final style = context.read<DeviceSettingsBloc>().state.citationStyle;
|
||||||
final exporter = StudysetMarkdownExporter(
|
final exporter = StudysetMarkdownExporter(
|
||||||
|
|
@ -118,60 +118,98 @@ Future<void> _exportMarkdown(BuildContext context, Studyset studyset) async {
|
||||||
person.pdfSources,
|
person.pdfSources,
|
||||||
person.assets.fileOf,
|
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
|
void say(String message) => messenger
|
||||||
..hideCurrentSnackBar()
|
..hideCurrentSnackBar()
|
||||||
..showSnackBar(SnackBar(content: Text(message)));
|
..showSnackBar(SnackBar(content: Text(message)));
|
||||||
|
|
||||||
say(l10n.exportRunning);
|
final ended = await runExport(context, stages: stages, work: work);
|
||||||
final StudysetMarkdown exported;
|
if (ended is! ExportStateDone) {
|
||||||
try {
|
say(switch (ended) {
|
||||||
exported = await exporter.export(
|
ExportStateFailed(error: ArchiveFilesMissing(:final count)) =>
|
||||||
studyset.id,
|
l10n.exportArchiveFilesMissing(count),
|
||||||
texts: exportTexts(l10n),
|
ExportStateFailed() => l10n.exportFailed,
|
||||||
style: style,
|
_ => l10n.exportAborted,
|
||||||
);
|
});
|
||||||
} on Object catch (_) {
|
|
||||||
say(l10n.exportFailed);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final name = _fileStem(studyset.content.title);
|
final file = ended.file;
|
||||||
final markdown = utf8.encode(exported.markdown);
|
|
||||||
final bool saved;
|
final bool saved;
|
||||||
try {
|
try {
|
||||||
if (exported.files.isEmpty) {
|
|
||||||
saved = await saver.save(
|
saved = await saver.save(
|
||||||
name: '$name.md',
|
name: file.name,
|
||||||
bytes: markdown,
|
bytes: file.bytes,
|
||||||
mimeType: 'text/markdown',
|
mimeType: file.mimeType,
|
||||||
);
|
);
|
||||||
} 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 (_) {
|
} on Object catch (_) {
|
||||||
say(l10n.exportFailed);
|
say(l10n.exportFailed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!saved) {
|
if (saved) {
|
||||||
messenger.hideCurrentSnackBar();
|
say(file.message);
|
||||||
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.
|
/// 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/data/asset_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/asset/model/stored_file.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/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_exporter.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_importer.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';
|
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/dictionary/data/dictionary_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.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/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/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/data/path_progress_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.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:convert';
|
||||||
import 'dart:typed_data';
|
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_payload.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/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/card/model/card_content.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/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/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';
|
||||||
|
|
@ -34,6 +38,7 @@ void main() {
|
||||||
late MemoryAssetFileCache cache;
|
late MemoryAssetFileCache cache;
|
||||||
late FakeFileSaver saver;
|
late FakeFileSaver saver;
|
||||||
late FakeReviewGateway reviewGateway;
|
late FakeReviewGateway reviewGateway;
|
||||||
|
late _HeldExportArchiver archiver;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
studysets = FakeStudysetGateway()
|
studysets = FakeStudysetGateway()
|
||||||
|
|
@ -46,6 +51,7 @@ void main() {
|
||||||
cache = MemoryAssetFileCache();
|
cache = MemoryAssetFileCache();
|
||||||
saver = FakeFileSaver();
|
saver = FakeFileSaver();
|
||||||
reviewGateway = FakeReviewGateway();
|
reviewGateway = FakeReviewGateway();
|
||||||
|
archiver = _HeldExportArchiver();
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<String> card(CardContent content) {
|
Future<String> card(CardContent content) {
|
||||||
|
|
@ -74,6 +80,7 @@ void main() {
|
||||||
assetFiles: cache,
|
assetFiles: cache,
|
||||||
fileSaver: saver,
|
fileSaver: saver,
|
||||||
reviewGateway: reviewGateway,
|
reviewGateway: reviewGateway,
|
||||||
|
exportArchiver: archiver,
|
||||||
),
|
),
|
||||||
location: RoutePaths.studyset('spanish'),
|
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', (
|
testWidgets('cancelled where to keep it, it says nothing more', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
@ -309,3 +333,14 @@ void main() {
|
||||||
expect(find.textContaining('Exported'), findsNothing);
|
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,
|
SoundPlayer? sounds,
|
||||||
FilePicker? files,
|
FilePicker? files,
|
||||||
FileSaver? fileSaver,
|
FileSaver? fileSaver,
|
||||||
|
ExportArchiver? exportArchiver,
|
||||||
YoutubeVideos? videos,
|
YoutubeVideos? videos,
|
||||||
PdfEngine? pdfEngine,
|
PdfEngine? pdfEngine,
|
||||||
SettingsGateway? settingsGateway,
|
SettingsGateway? settingsGateway,
|
||||||
|
|
@ -255,7 +256,7 @@ Dependencies testDependencies({
|
||||||
pdfEngine: engine,
|
pdfEngine: engine,
|
||||||
ankiPackages: InlineAnkiPackageReader(),
|
ankiPackages: InlineAnkiPackageReader(),
|
||||||
archives: InlineArchiveReader(),
|
archives: InlineArchiveReader(),
|
||||||
exportArchiver: InlineExportArchiver(),
|
exportArchiver: exportArchiver ?? InlineExportArchiver(),
|
||||||
deviceSettingsStore:
|
deviceSettingsStore:
|
||||||
deviceSettingsStore ?? MemoryDeviceSettingsStore(deviceSettings),
|
deviceSettingsStore ?? MemoryDeviceSettingsStore(deviceSettings),
|
||||||
deviceSettings: deviceSettings,
|
deviceSettings: deviceSettings,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue