feat(learn): an archive's import shows its steps, counts and percent, with Abort

Importing a Halkyon archive showed one bar. It now says which of its three
steps it is in (copying files; writing studysets, flashcards and notes;
bringing over the progress), how many of the step's items are done and the
percent of all of it, and offers Abort (decided 9 Nov 2026).

The copier checks between items, never leaving one half written, and
keeps a trail of what it wrote: the top studyset once made, and the files'
rows it made (not those the person had, found before each file is added).
Aborted, the importer deletes the top studyset for good with everything
inside it, as Delete now does (the reviews, answers and pages read going
with their cards, steps and PDFs), and those rows; the tags it made stay,
as names the person may well use anyway. Once all of it is written, Abort
no longer undoes it.

WorkProgress, WorkStages and AbortSignal (core/work/) and
WorkProgressView (core/widget/) are shared with the Anki import and the
exports, which follow.

Whats-New: Importing an archive shows its steps and how far it has come, and Abort stops it, keeping nothing of it.
Whats-New-De: Der Import eines Archivs zeigt seine Schritte und wie weit er ist, und „Abbrechen“ beendet ihn, ohne etwas davon zu behalten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-10 15:53:31 +01:00
commit 5f7cb70c3c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
11 changed files with 901 additions and 53 deletions

View file

@ -2097,5 +2097,22 @@
"assistantAddTitle": "Zu deiner Frage hinzufügen",
"assistantAddCamera": "Kamera",
"assistantAddPhotos": "Fotos",
"assistantAddFiles": "Dateien"
"assistantAddFiles": "Dateien",
"workStep": "Schritt {step} von {steps}: {name}",
"workCount": "{done} von {total}",
"workPercent": "{percent} %",
"workAbort": "Abbrechen",
"workAborting": "Wird abgebrochen …",
"workStageFiles": "Dateien kopieren",
"workStageContent": "Lernsets, Karteikarten und Notizen schreiben",
"workStageProgress": "Deinen Fortschritt übernehmen",
"workStageCards": "Karteikarten schreiben",
"workStageTags": "Tags vergeben",
"workStageReviews": "Den Lernverlauf übernehmen",
"workStageReading": "Inhalte lesen",
"workStageGathering": "Dateien sammeln",
"workStagePacking": "Packen",
"importAborted": "Import abgebrochen: Nichts davon wurde behalten.",
"exportAborted": "Export abgebrochen.",
"exportProgressTitle": "Wird exportiert"
}

View file

@ -2518,5 +2518,39 @@
"assistantAddPhotos": "Photos",
"@assistantAddPhotos": {"description": "Tile in +'s sheet: picks photos from the device."},
"assistantAddFiles": "Files",
"@assistantAddFiles": {"description": "Tile in +'s sheet: picks files (PDFs, texts, images)."}
"@assistantAddFiles": {"description": "Tile in +'s sheet: picks files (PDFs, texts, images)."},
"workStep": "Step {step} of {steps}: {name}",
"@workStep": {"description": "An import's or export's progress: the step it is in, of all, and its name (decided 9 Nov 2026).", "placeholders": {"step": {"type": "int"}, "steps": {"type": "int"}, "name": {"type": "String"}}},
"workCount": "{done} of {total}",
"@workCount": {"description": "An import's or export's progress: how many of the step's items are done, the numbers formatted.", "placeholders": {"done": {"type": "String"}, "total": {"type": "String"}}},
"workPercent": "{percent}%",
"@workPercent": {"description": "An import's or export's progress: how much of all of it is done.", "placeholders": {"percent": {"type": "int"}}},
"workAbort": "Abort",
"@workAbort": {"description": "Stops an import or an export under way; an import's Abort deletes what it made."},
"workAborting": "Aborting…",
"@workAborting": {"description": "An import or export stopping after Abort, an import deleting what it made."},
"workStageFiles": "Copying files",
"@workStageFiles": {"description": "An import's step: the files its content names."},
"workStageContent": "Writing studysets, flashcards and notes",
"@workStageContent": {"description": "An archive import's step: its content."},
"workStageProgress": "Bringing over your progress",
"@workStageProgress": {"description": "An archive import's step: reviews, answers and pages read."},
"workStageCards": "Writing flashcards",
"@workStageCards": {"description": "An Anki import's step: its notes as flashcards."},
"workStageTags": "Tagging",
"@workStageTags": {"description": "An Anki import's step: the flashcards' tags."},
"workStageReviews": "Bringing over the review history",
"@workStageReviews": {"description": "An Anki import's step: its reviews."},
"workStageReading": "Reading the content",
"@workStageReading": {"description": "An export's step: the studysets and what is in them."},
"workStageGathering": "Gathering files",
"@workStageGathering": {"description": "An export's step: the files its content names."},
"workStagePacking": "Packing",
"@workStagePacking": {"description": "An export's step: the zip file made."},
"importAborted": "Import aborted: nothing of it was kept.",
"@importAborted": {"description": "Snack bar after an import's Abort, which deleted what it had made."},
"exportAborted": "Export aborted.",
"@exportAborted": {"description": "Snack bar after an export's Abort."},
"exportProgressTitle": "Exporting",
"@exportProgressTitle": {"description": "The sheet showing an export's progress."}
}

View file

@ -12367,6 +12367,108 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Files'**
String get assistantAddFiles;
/// An import's or export's progress: the step it is in, of all, and its name (decided 9 Nov 2026).
///
/// In en, this message translates to:
/// **'Step {step} of {steps}: {name}'**
String workStep(int step, int steps, String name);
/// An import's or export's progress: how many of the step's items are done, the numbers formatted.
///
/// In en, this message translates to:
/// **'{done} of {total}'**
String workCount(String done, String total);
/// An import's or export's progress: how much of all of it is done.
///
/// In en, this message translates to:
/// **'{percent}%'**
String workPercent(int percent);
/// Stops an import or an export under way; an import's Abort deletes what it made.
///
/// In en, this message translates to:
/// **'Abort'**
String get workAbort;
/// An import or export stopping after Abort, an import deleting what it made.
///
/// In en, this message translates to:
/// **'Aborting…'**
String get workAborting;
/// An import's step: the files its content names.
///
/// In en, this message translates to:
/// **'Copying files'**
String get workStageFiles;
/// An archive import's step: its content.
///
/// In en, this message translates to:
/// **'Writing studysets, flashcards and notes'**
String get workStageContent;
/// An archive import's step: reviews, answers and pages read.
///
/// In en, this message translates to:
/// **'Bringing over your progress'**
String get workStageProgress;
/// An Anki import's step: its notes as flashcards.
///
/// In en, this message translates to:
/// **'Writing flashcards'**
String get workStageCards;
/// An Anki import's step: the flashcards' tags.
///
/// In en, this message translates to:
/// **'Tagging'**
String get workStageTags;
/// An Anki import's step: its reviews.
///
/// In en, this message translates to:
/// **'Bringing over the review history'**
String get workStageReviews;
/// An export's step: the studysets and what is in them.
///
/// In en, this message translates to:
/// **'Reading the content'**
String get workStageReading;
/// An export's step: the files its content names.
///
/// In en, this message translates to:
/// **'Gathering files'**
String get workStageGathering;
/// An export's step: the zip file made.
///
/// In en, this message translates to:
/// **'Packing'**
String get workStagePacking;
/// Snack bar after an import's Abort, which deleted what it had made.
///
/// In en, this message translates to:
/// **'Import aborted: nothing of it was kept.'**
String get importAborted;
/// Snack bar after an export's Abort.
///
/// In en, this message translates to:
/// **'Export aborted.'**
String get exportAborted;
/// The sheet showing an export's progress.
///
/// In en, this message translates to:
/// **'Exporting'**
String get exportProgressTitle;
}
class _AppLocalizationsDelegate

View file

@ -7959,4 +7959,62 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get assistantAddFiles => 'Dateien';
@override
String workStep(int step, int steps, String name) {
return 'Schritt $step von $steps: $name';
}
@override
String workCount(String done, String total) {
return '$done von $total';
}
@override
String workPercent(int percent) {
return '$percent %';
}
@override
String get workAbort => 'Abbrechen';
@override
String get workAborting => 'Wird abgebrochen …';
@override
String get workStageFiles => 'Dateien kopieren';
@override
String get workStageContent => 'Lernsets, Karteikarten und Notizen schreiben';
@override
String get workStageProgress => 'Deinen Fortschritt übernehmen';
@override
String get workStageCards => 'Karteikarten schreiben';
@override
String get workStageTags => 'Tags vergeben';
@override
String get workStageReviews => 'Den Lernverlauf übernehmen';
@override
String get workStageReading => 'Inhalte lesen';
@override
String get workStageGathering => 'Dateien sammeln';
@override
String get workStagePacking => 'Packen';
@override
String get importAborted =>
'Import abgebrochen: Nichts davon wurde behalten.';
@override
String get exportAborted => 'Export abgebrochen.';
@override
String get exportProgressTitle => 'Wird exportiert';
}

View file

@ -7887,6 +7887,63 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get assistantAddFiles => 'Files';
@override
String workStep(int step, int steps, String name) {
return 'Step $step of $steps: $name';
}
@override
String workCount(String done, String total) {
return '$done of $total';
}
@override
String workPercent(int percent) {
return '$percent%';
}
@override
String get workAbort => 'Abort';
@override
String get workAborting => 'Aborting…';
@override
String get workStageFiles => 'Copying files';
@override
String get workStageContent => 'Writing studysets, flashcards and notes';
@override
String get workStageProgress => 'Bringing over your progress';
@override
String get workStageCards => 'Writing flashcards';
@override
String get workStageTags => 'Tagging';
@override
String get workStageReviews => 'Bringing over the review history';
@override
String get workStageReading => 'Reading the content';
@override
String get workStageGathering => 'Gathering files';
@override
String get workStagePacking => 'Packing';
@override
String get importAborted => 'Import aborted: nothing of it was kept.';
@override
String get exportAborted => 'Export aborted.';
@override
String get exportProgressTitle => 'Exporting';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -0,0 +1,76 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/work/work_progress.dart';
import 'package:intl/intl.dart';
import 'package:material_ui/material_ui.dart';
/// How far an import or an export has come (decided 9 Nov 2026): its step
/// of all and its name, how many of the step's items are done, the whole's
/// percent and bar, and Abort while [onAbort] is given; once asked,
/// [aborting] says it is stopping.
class const WorkProgressView<S extends Enum>({
required final WorkProgress<S> progress,
required final String Function(S stage) stageName,
final VoidCallback? onAbort,
final bool aborting = false,
super.key,
}) extends StatelessWidget {
/// The key of its Abort button.
static const abortKey = Key('workAbort');
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final number = NumberFormat.decimalPattern(
Localizations.localeOf(context).toString(),
);
final muted = theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text(
aborting
? l10n.workAborting
: l10n.workStep(
progress.step,
progress.steps,
stageName(progress.stage),
),
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
LinearProgressIndicator(value: progress.fraction),
const SizedBox(height: 4),
Row(
children: [
if (progress.total > 0)
Expanded(
child: Text(
l10n.workCount(
number.format(progress.done),
number.format(progress.total),
),
style: muted,
),
)
else
const Spacer(),
Text(l10n.workPercent(progress.percent), style: muted),
],
),
if (onAbort != null || aborting)
Align(
alignment: AlignmentDirectional.centerEnd,
child: TextButton(
key: abortKey,
onPressed: aborting ? null : onAbort,
child: Text(l10n.workAbort),
),
),
],
);
}
}

View file

@ -0,0 +1,74 @@
import 'package:equatable/equatable.dart';
/// Abort was asked for: the work stops at its next [AbortSignal.check].
final class const WorkAborted() implements Exception {
@override
String toString() => 'WorkAborted';
}
/// How a long piece of work (an import, an export) learns that the person
/// asked to abort it (decided 9 Nov 2026): it checks between its items, so
/// what it is writing is never left half written.
class AbortSignal() {
var _aborted = false;
/// Whether abort was asked for.
bool get aborted => _aborted;
/// Asks the work to stop.
void abort() => _aborted = true;
/// Throws [WorkAborted] once abort was asked for.
void check() {
if (_aborted) {
throw const WorkAborted();
}
}
}
/// How far a long piece of work has come (decided 9 Nov 2026: steps,
/// counts, percent): in [stage], the [step]th of [steps] (from 1), [done]
/// of its [total] items (none counted where [total] is 0), and the whole
/// work's [fraction], from 0 to 1.
final class const WorkProgress<S extends Enum>({
required final S stage,
required final int step,
required final int steps,
final int done = 0,
final int total = 0,
required final double fraction,
}) with Equatable {
/// The whole work's share done, in whole percent, 0 to 100.
int get percent => (fraction.clamp(0, 1) * 100).floor();
@override
List<Object?> get props => [stage, step, steps, done, total, fraction];
}
/// Works out [WorkProgress] for work going through [stages] in order, each
/// weighing on the whole as [weights] says (by default each the same).
class WorkStages<S extends Enum>(
final List<S> stages, {
final Map<S, double> weights = const {},
}) {
double _weight(S stage) => weights[stage] ?? 1;
late final double _all = stages.fold(0, (sum, s) => sum + _weight(s));
/// Where the work stands in [stage], [done] of [total] of it.
WorkProgress<S> at(S stage, {int done = 0, int total = 0}) {
final index = stages.indexOf(stage);
final before = stages
.take(index)
.fold<double>(0, (sum, s) => sum + _weight(s));
final within = total == 0 ? 0.0 : (done / total).clamp(0.0, 1.0);
return WorkProgress(
stage: stage,
step: index + 1,
steps: stages.length,
done: done,
total: total,
fraction: _all == 0 ? 0 : (before + within * _weight(stage)) / _all,
);
}
}

View file

@ -2,18 +2,22 @@ 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/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/model/import_archive.dart';
/// Importing an archive: read, then shown with what it holds, then, once
/// confirmed, imported, its progress told; done, or failed with why.
/// confirmed, imported, its progress told, until done, aborted (what it
/// wrote deleted) or failed with why.
class ArchiveImportCubit(
final ArchiveReader _reader,
final ArchiveImporter _importer,
) extends Cubit<ArchiveImportState> {
this : super(const ArchiveImportStateReading());
final _abort = AbortSignal();
/// Reads the archive in [bytes].
Future<void> start(Uint8List bytes) async {
try {
@ -31,26 +35,44 @@ class ArchiveImportCubit(
/// Imports the archive read.
Future<void> confirm() async {
if (state case ArchiveImportStateReady(:final archive, :final title)) {
emit(
ArchiveImportStateRunning(archive: archive, title: title, progress: 0),
);
emit(ArchiveImportStateRunning(archive: archive, title: title));
try {
final result = await _importer.import(
archive,
onProgress: (done) => emit(
abort: _abort,
onProgress: (progress) => emit(
ArchiveImportStateRunning(
archive: archive,
title: title,
progress: done,
progress: progress,
aborting: _abort.aborted,
),
),
);
emit(ArchiveImportStateDone(result));
} on WorkAborted {
emit(const ArchiveImportStateAborted());
} on Object {
emit(const ArchiveImportStateFailed(null));
}
}
}
/// Stops the import under way, which then deletes what it wrote.
void abort() {
if (state case final ArchiveImportStateRunning running
when !running.aborting) {
_abort.abort();
emit(
ArchiveImportStateRunning(
archive: running.archive,
title: running.title,
progress: running.progress,
aborting: true,
),
);
}
}
}
/// Where an import of an archive is.
@ -72,16 +94,21 @@ final class const ArchiveImportStateReady({
List<Object?> get props => [archive, title];
}
/// The archive is being imported, [progress] of it done (0 to 1).
/// The archive is being imported, as far as [progress] (none told yet:
/// starting), or, [aborting], what it wrote being deleted.
final class const ArchiveImportStateRunning({
required final ImportArchive archive,
required final String title,
required final double progress,
final WorkProgress<ArchiveImportStage>? progress,
final bool aborting = false,
}) extends ArchiveImportState {
@override
List<Object?> get props => [archive, title, progress];
List<Object?> get props => [archive, title, progress, aborting];
}
/// The import was aborted, and what it wrote deleted.
final class const ArchiveImportStateAborted() extends ArchiveImportState;
/// The archive was imported: [result].
final class const ArchiveImportStateDone(final ArchiveImported result)
extends ArchiveImportState {

View file

@ -1,6 +1,8 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/work/work_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/model/import_archive.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
@ -11,6 +13,7 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_rep
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
@ -35,6 +38,14 @@ typedef ArchiveImported = ({
int missingEntries,
});
/// The steps of an archive's import (decided 9 Nov 2026): its files, its
/// content, then the progress.
enum ArchiveImportStage() {
files,
content,
progress,
}
/// Brings an archive ([ImportArchive]) into the person's library as their
/// own: its tags in their colours (a tag the person has keeps theirs); the
/// studysets, cards, notes, learning paths and PDFs through the copier, as
@ -43,6 +54,11 @@ typedef ArchiveImported = ({
/// makes them; then the progress, so every schedule and path goes on from
/// it (each is replayed from its log): the reviews, the answers to steps,
/// the pages read; and which studysets are archived and starred.
///
/// Aborted, it deletes what it wrote (decided 9 Nov 2026): the top studyset
/// for good with everything inside it, as Delete now does, and the files'
/// rows it made. Tags it made stay, as a tag is a name the person may well
/// have used anyway.
class ArchiveImporter(
final ReleaseCopier _copier,
final DictionaryRepository _dictionaries,
@ -50,19 +66,63 @@ class ArchiveImporter(
final PathProgressRepository _progress,
final StudysetRepository _studysets,
final PdfSourceRepository _pdfs,
final StudysetDeletions _deletions,
final AssetRepository _assets,
) {
/// What the archive's cards say they came from, as an Anki deck's say
/// "anki": the dashboard doesn't count them as written here.
static const cardOrigin = 'archive';
/// Imports [archive]; tells [onProgress] how much is done, from 0 to 1.
/// Throws `ReleaseUnreadable` before writing anything when this app
/// can't read all of it.
static final _stages = WorkStages(
ArchiveImportStage.values,
weights: const {ArchiveImportStage.content: 2},
);
/// Imports [archive]; tells [onProgress] how far it has come. Throws
/// `ReleaseUnreadable` before writing anything when this app can't read
/// all of it. Once [abort] is asked, stops between items, deletes what it
/// wrote and throws [WorkAborted].
Future<ArchiveImported> import(
ImportArchive archive, {
void Function(double done)? onProgress,
void Function(WorkProgress<ArchiveImportStage> progress)? onProgress,
AbortSignal? abort,
}) async {
final (:items, :missing) = await _withEntries(archive.items);
final trail = CopyTrail();
try {
return await _import(
archive,
items: items,
missing: missing,
trail: trail,
onProgress: onProgress,
abort: abort,
);
} on WorkAborted {
await _discard(trail);
rethrow;
}
}
/// Deletes what [trail] says an import wrote.
Future<void> _discard(CopyTrail trail) async {
if (trail.top case final top?) {
await _deletions.deleteForGood(top);
}
for (final id in trail.newFiles) {
await _assets.delete(id);
}
}
Future<ArchiveImported> _import(
ImportArchive archive, {
required Map<String, Map<String, Object?>> items,
required int missing,
required CopyTrail trail,
required void Function(WorkProgress<ArchiveImportStage> progress)?
onProgress,
required AbortSignal? abort,
}) async {
final (:top, :ids) = await _copier.write(
PublishedRelease(publicId: '', version: 0, items: items),
fileOf: (sha256) async => archive.files[sha256]!,
@ -74,8 +134,32 @@ class ArchiveImporter(
fromJellyfin: tag.fromJellyfin,
),
},
onProgress: onProgress,
onStage: (stage, done, total) => onProgress?.call(
_stages.at(
switch (stage) {
CopyStage.files => ArchiveImportStage.files,
CopyStage.content => ArchiveImportStage.content,
},
done: done,
total: total,
),
),
abort: abort,
trail: trail,
);
// The progress in four parts, each checked for abort after it.
var parts = 0;
void part() {
onProgress?.call(
_stages.at(ArchiveImportStage.progress, done: ++parts, total: 4),
);
// Once all of it is written, it is done.
if (parts < 4) {
abort?.check();
}
}
onProgress?.call(_stages.at(ArchiveImportStage.progress, total: 4));
final reviews = [
for (final review in archive.reviews)
if (ids[review.card] case final cardId?)
@ -93,6 +177,7 @@ class ArchiveImporter(
),
];
await _reviews.recordAll(reviews);
part();
await _progress.recordAll([
for (final answer in archive.answers)
if (ids[answer.step] case final stepId?)
@ -103,6 +188,7 @@ class ArchiveImporter(
answer: answer.answer,
),
]);
part();
await _pdfs.recordReadings([
for (final reading in archive.readings)
if (ids[reading.pdf] case final pdfId?)
@ -113,6 +199,7 @@ class ArchiveImporter(
page: reading.page,
),
]);
part();
for (final id in archive.archived) {
if (ids[id] case final studysetId?) {
await _studysets.archive(studysetId);
@ -123,6 +210,7 @@ class ArchiveImporter(
await _studysets.star(studysetId);
}
}
part();
return (
studysetId: top,
cards: archive.cards - missing,

View file

@ -2,6 +2,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/widget/work_progress_view.dart';
import 'package:halkyon_learn_flutter/src/core/work/work_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/bloc/archive_import_cubit.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';
@ -11,8 +13,8 @@ import 'package:material_ui/material_ui.dart';
/// Imports an archive the person picks (see `ImportArchive`): what it
/// holds first, then, confirmed, all of it as their own, its progress
/// showing and nothing closing it meanwhile; says how it went. Returns the
/// top studyset made; null when nothing was imported.
/// showing with Abort and nothing else closing it meanwhile; says how it
/// went. Returns the top studyset made; null when nothing was imported.
Future<String?> importArchive(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
@ -22,7 +24,7 @@ Future<String?> importArchive(BuildContext context) async {
if (file == null || !context.mounted) {
return null;
}
final done = await showAdaptiveSheet<ArchiveImported>(
final ended = await showAdaptiveSheet<ArchiveImportState>(
context,
// Swiping away would leave an import half done, without asking.
swipeable: false,
@ -47,12 +49,21 @@ Future<String?> importArchive(BuildContext context) async {
person.pathProgress,
person.studysets,
person.pdfSources,
person.deletions,
person.assets,
),
)..start(file.bytes),
child: const _ArchiveImport(),
);
},
);
final done = switch (ended) {
ArchiveImportStateDone(:final result) => result,
_ => null,
};
if (ended is ArchiveImportStateAborted) {
messenger.showSnackBar(SnackBar(content: Text(l10n.importAborted)));
}
if (done == null) {
return null;
}
@ -79,10 +90,10 @@ class const _ArchiveImport() extends StatelessWidget {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return BlocConsumer<ArchiveImportCubit, ArchiveImportState>(
listenWhen: (previous, current) => current is ArchiveImportStateDone,
listener: (context, state) => Navigator.of(
context,
).pop((state as ArchiveImportStateDone).result),
listenWhen: (previous, current) =>
current is ArchiveImportStateDone ||
current is ArchiveImportStateAborted,
listener: (context, state) => Navigator.of(context).pop(state),
builder: (context, state) {
final running = state is ArchiveImportStateRunning;
return SheetScaffold(
@ -90,17 +101,16 @@ class const _ArchiveImport() extends StatelessWidget {
// Closing would leave half the archive imported.
busy: running,
footer: switch (state) {
ArchiveImportStateReady() ||
ArchiveImportStateRunning() => FilledButton(
onPressed: running
? null
: () => context.read<ArchiveImportCubit>().confirm(),
ArchiveImportStateReady() => FilledButton(
onPressed: () => context.read<ArchiveImportCubit>().confirm(),
child: Text(l10n.archiveImportConfirm),
),
_ => null,
},
body: switch (state) {
ArchiveImportStateReading() || ArchiveImportStateDone() => Padding(
ArchiveImportStateReading() ||
ArchiveImportStateDone() ||
ArchiveImportStateAborted() => Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Column(
children: [
@ -139,9 +149,28 @@ class const _ArchiveImport() extends StatelessWidget {
l10n.archiveCountReviews(archive.reviews.length),
])
Text('· $line', style: theme.textTheme.bodyMedium),
if (state case ArchiveImportStateRunning(:final progress)) ...[
if (state case ArchiveImportStateRunning(
:final progress,
:final aborting,
)) ...[
const SizedBox(height: 16),
LinearProgressIndicator(value: progress),
WorkProgressView(
progress:
progress ??
const WorkProgress<ArchiveImportStage>(
stage: ArchiveImportStage.files,
step: 1,
steps: 3,
fraction: 0,
),
stageName: (stage) => switch (stage) {
ArchiveImportStage.files => l10n.workStageFiles,
ArchiveImportStage.content => l10n.workStageContent,
ArchiveImportStage.progress => l10n.workStageProgress,
},
onAbort: () => context.read<ArchiveImportCubit>().abort(),
aborting: aborting,
),
],
],
),

View file

@ -4,6 +4,7 @@ import 'dart:typed_data';
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/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/core/work/work_progress.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/model/card_content.dart';
@ -37,6 +38,24 @@ final class ReleaseUnreadable() implements Exception {
String toString() => 'ReleaseUnreadable';
}
/// The stages of writing a release into the library: its files, then its
/// studysets, cards, notes, paths and PDFs.
enum CopyStage() {
files,
content,
}
/// What a copy wrote so far, for taking it back where it is aborted
/// (decided 9 Nov 2026): its top studyset, once made, with everything else
/// inside it, and the files' rows it made (not those the person had).
final class CopyTrail() {
/// The top studyset, once made.
String? top;
/// The asset rows made for the release's files.
final newFiles = <String>[];
}
/// Makes a copy of a published studyset's release in the person's library
/// (ADR 0005, rule 4): their own studysets, cards, notes, learning paths and
/// PDFs, with the files the content names among their own files and the
@ -90,16 +109,25 @@ class ReleaseCopier(
/// ones do; a tag it makes looks as [tags] says (by its
/// `canonicalTagName`), an archive keeping its colour and whether it was
/// made for Jellyfin.
///
/// Tells [onStage] each stage's count as it goes, and stops between
/// items with [WorkAborted] once [abort] is asked, [trail] holding what
/// it wrote by then.
Future<({String top, Map<String, String> ids})> write(
PublishedRelease release, {
required Future<Uint8List> Function(String sha256) fileOf,
required String cardOrigin,
Map<String, TagLook> tags = const {},
void Function(double done)? onProgress,
void Function(CopyStage stage, int done, int total)? onStage,
AbortSignal? abort,
CopyTrail? trail,
}) => _copy(
release,
fileOf: fileOf,
onProgress: onProgress,
onProgress: null,
onStage: onStage,
abort: abort,
trail: trail,
origin: null,
cardOrigin: cardOrigin,
tags: tags,
@ -110,6 +138,9 @@ class ReleaseCopier(
required Future<Uint8List> Function(String sha256) fileOf,
required void Function(double done)? onProgress,
required PublishedRef Function(String id)? origin,
void Function(CopyStage stage, int done, int total)? onStage,
AbortSignal? abort,
CopyTrail? trail,
String? location,
String? cardOrigin,
Map<String, TagLook> tags = const {},
@ -125,8 +156,33 @@ class ReleaseCopier(
files.length +
release.items.length -
(release.items.containsKey('files.json') ? 1 : 0);
var done = 0;
void step() => onProgress?.call(++done / total);
final contentTotal = total - files.length;
var filesDone = 0;
var contentDone = 0;
void report(CopyStage stage) {
onProgress?.call((filesDone + contentDone) / total);
onStage?.call(
stage,
stage == CopyStage.files ? filesDone : contentDone,
stage == CopyStage.files ? files.length : contentTotal,
);
}
// Each counts what was written, then stops before the next where
// abort was asked.
void step([int items = 1]) {
contentDone += items;
report(CopyStage.content);
abort?.check();
}
void fileStep() {
filesDone++;
report(CopyStage.files);
abort?.check();
}
abort?.check();
// Each item's id in the release, and the id it has now.
final ids = <String, String>{};
@ -140,7 +196,10 @@ class ReleaseCopier(
final root = studysetIds[''] = ids[rootItem.id] = await _studysets.create(
rootContent(rootItem.content),
);
step();
trail?.top = root;
contentDone++;
report(CopyStage.files);
abort?.check();
// The files, as the person's own in it: the content then names them.
final assetIds = <String, String>{};
@ -149,7 +208,10 @@ class ReleaseCopier(
'sha256': final String sha256,
'mimeType': final String mimeType,
}) {
assetIds[id] = await _assets.add(
final had = trail == null
? null
: await _assets.idOf(sha256, studysetId: root);
final assetId = assetIds[id] = await _assets.add(
await fileOf(sha256),
fileName: switch (file['payload']) {
{'fileName': final String name} when name.isNotEmpty => name,
@ -158,9 +220,13 @@ class ReleaseCopier(
mimeType: mimeType,
studysetId: root,
);
if (had == null && !(trail?.newFiles.contains(assetId) ?? true)) {
trail!.newFiles.add(assetId);
}
}
step();
fileStep();
}
report(CopyStage.content);
final read = _read(release, assetIds);
// The top studyset names its own files now too.
final withFiles = rootContent(read.studysets['']!.content);
@ -274,8 +340,7 @@ class ReleaseCopier(
},
);
tagIds.addAll(tagged);
done += cards.length;
onProgress?.call(done / total);
step(cards.length);
for (final note in _ordered(named.notes[directory])) {
final (:noteId, revisionId: _) = await _notes.create(