feat(app): Import in the Library's bar

Beside search, as in the old app: Import offers an Anki deck, which
becomes a studyset of its own named after its top deck (or its file),
its subdecks inside as chosen. The studyset is made only once the
import starts, so closing the sheet before leaves nothing behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-08 14:47:23 +02:00
commit a26150a314
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
10 changed files with 190 additions and 9 deletions

View file

@ -46,6 +46,8 @@
"studysetCountNotes": "{count, plural, =1{1 Notiz} other{{count} Notizen}}",
"studysetCountFiles": "{count, plural, =1{1 Datei} other{{count} Dateien}}",
"libraryRefresh": "Aktualisieren",
"libraryImport": "Importieren",
"libraryImportAnkiHint": "Wird ein eigenes Lernset, aus einer .apkg- oder .colpkg-Datei",
"libraryRetry": "Erneut versuchen",
"studysetNoContent": "Noch kein Inhalt",
"studysetEmpty": "Leer",

View file

@ -52,6 +52,8 @@
"studysetCountFiles": "{count, plural, =1{1 file} other{{count} files}}",
"@studysetCountFiles": {"placeholders": {"count": {"type": "int"}}},
"libraryRefresh": "Refresh",
"libraryImport": "Import",
"libraryImportAnkiHint": "Becomes a studyset of its own, from an .apkg or .colpkg file",
"libraryRetry": "Retry",
"studysetNoContent": "No content yet",
"studysetEmpty": "Empty",

View file

@ -334,6 +334,18 @@ abstract class AppLocalizations {
/// **'Refresh'**
String get libraryRefresh;
/// No description provided for @libraryImport.
///
/// In en, this message translates to:
/// **'Import'**
String get libraryImport;
/// No description provided for @libraryImportAnkiHint.
///
/// In en, this message translates to:
/// **'Becomes a studyset of its own, from an .apkg or .colpkg file'**
String get libraryImportAnkiHint;
/// No description provided for @libraryRetry.
///
/// In en, this message translates to:

View file

@ -185,6 +185,13 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get libraryRefresh => 'Aktualisieren';
@override
String get libraryImport => 'Importieren';
@override
String get libraryImportAnkiHint =>
'Wird ein eigenes Lernset, aus einer .apkg- oder .colpkg-Datei';
@override
String get libraryRetry => 'Erneut versuchen';

View file

@ -183,6 +183,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get libraryRefresh => 'Refresh';
@override
String get libraryImport => 'Import';
@override
String get libraryImportAnkiHint =>
'Becomes a studyset of its own, from an .apkg or .colpkg file';
@override
String get libraryRetry => 'Retry';

View file

@ -9,12 +9,14 @@ import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.dart';
/// Importing one Anki package into the studyset [_studysetId]: reading it,
/// choosing how, and bringing it in.
/// choosing how, and bringing it in. Without a studyset it goes into a new
/// one of its own, made only once the import starts, so that closing the
/// sheet before leaves nothing behind.
class AnkiImportBloc(
final AnkiPackageReader _reader,
final AnkiImporter _importer,
final AssetRepository _assets,
final String _studysetId,
final String? _studysetId,
) extends Bloc<AnkiImportEvent, AnkiImportState> {
this : super(const AnkiImportStateReading()) {
on<AnkiImportEventStarted>(_onStarted);
@ -28,6 +30,9 @@ class AnkiImportBloc(
AnkiPackage? _package;
/// The name of the package's file, without its extension.
var _fileTitle = '';
Future<void> _onStarted(
AnkiImportEventStarted event,
Emitter<AnkiImportState> emit,
@ -41,6 +46,9 @@ class AnkiImportBloc(
return;
}
_package = package;
final name = event.fileName;
final dot = name.lastIndexOf('.');
_fileTitle = dot > 0 ? name.substring(0, dot) : name;
final collection = package.collection;
final media = referencedMedia(collection);
final room = media.isEmpty ? null : await _assets.room();
@ -82,7 +90,12 @@ class AnkiImportBloc(
try {
final result = await _importer.import(
package,
studysetId: _studysetId,
studysetId:
_studysetId ??
await _importer.newStudysetFor(
package,
fallbackTitle: _fileTitle,
),
mode: options.mode,
keepProgress: options.keepProgress && ready.summary.hasReviews,
includeMedia: options.includeMedia,
@ -174,10 +187,14 @@ sealed class const AnkiImportEvent() with Equatable {
/// The package in [bytes] is to be imported: read it.
final class const AnkiImportEventStarted(
/// The package.
final Uint8List bytes,
) extends AnkiImportEvent {
final Uint8List bytes, {
/// The name of its file, which names a new studyset for a package
/// without decks.
final String fileName = '',
}) extends AnkiImportEvent {
@override
List<Object?> get props => [bytes];
List<Object?> get props => [bytes, fileName];
}
/// Import as [options] say.

View file

@ -54,6 +54,21 @@ class AnkiImporter(
final ReviewRepository _reviews,
final AssetRepository _assets,
) {
/// Makes a studyset of its own at the top of the library for [package],
/// named after the deck all its notes are in or below, or [fallbackTitle]
/// for a package without one; returns its id, to import into.
Future<String> newStudysetFor(
AnkiPackage package, {
required String fallbackTitle,
}) {
final top = _commonTop([
for (final note in package.collection.notes) note.deckPath,
]);
return _studysets.create(
StudysetContent.titled(top.isEmpty ? fallbackTitle : top.last),
);
}
/// Imports [package] into the studyset [studysetId] as [mode] says, its
/// review history if [keepProgress], its files if [includeMedia];
/// [onProgress] hears how far it got, from 0 to 1.

View file

@ -10,12 +10,13 @@ import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package.dart';
import 'package:material_ui/material_ui.dart';
/// Lets the person pick an Anki package (`.apkg`, or a whole collection's
/// `.colpkg`) and imports it into the studyset [studysetId], as they choose
/// `.colpkg`) and imports it into the studyset [studysetId], or without one
/// into a new studyset of its own named after its top deck, as they choose
/// in a sheet; then says what came over. Completes with how its decks became
/// studysets, or with null when nothing was imported.
Future<AnkiImportMode?> importFromAnki(
BuildContext context, {
required String studysetId,
String? studysetId,
}) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
@ -44,7 +45,7 @@ Future<AnkiImportMode?> importFromAnki(
),
person.assets,
studysetId,
)..add(AnkiImportEventStarted(file.bytes)),
)..add(AnkiImportEventStarted(file.bytes, fileName: file.name)),
child: const _AnkiImport(),
);
},

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
@ -6,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_and_wait.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/scroll_hiding_fab.dart';
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
@ -86,6 +89,35 @@ class _LibraryState() extends State<_Library> {
}
}
/// Asks what to import as new studysets, and imports it: for now an Anki
/// deck, which becomes a studyset of its own. Learning paths and
/// flashcards as text go into a studyset, from its Import content.
Future<void> _import(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final anki = await showAdaptiveSheet<bool>(
context,
builder: (sheetContext) => SheetScaffold(
title: l10n.libraryImport,
body: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.graduationCap),
title: Text(l10n.studysetImportAnki),
subtitle: Text(l10n.libraryImportAnkiHint),
onTap: () => Navigator.of(sheetContext).pop(true),
),
],
),
),
);
if ((anki ?? false) && context.mounted) {
await importFromAnki(context);
}
}
Future<void> _create(BuildContext context) async {
final bloc = context.read<StudysetListBloc>();
final content = await showStudysetSheet(context);
@ -122,6 +154,11 @@ class _LibraryState() extends State<_Library> {
),
onPressed: _toggleSearch,
),
IconButton(
tooltip: l10n.libraryImport,
icon: const Icon(LucideIcons.fileInput),
onPressed: () => unawaited(_import(context)),
),
IconButton(
tooltip: l10n.libraryRefresh,
icon: const Icon(LucideIcons.refreshCw),

View file

@ -7,6 +7,7 @@ import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.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/studyset/model/studyset_content.dart';
import 'package:material_ui/material_ui.dart';
@ -121,6 +122,86 @@ void main() {
expect(await cards.cardsOf('japanese'), hasLength(4));
});
testWidgets("the Library's Import makes a deck a studyset of its own, "
'named after its top deck, its subdecks inside as chosen', (
tester,
) async {
final studysets = FakeStudysetGateway();
files.next = (name: 'export.apkg', bytes: fixture('anki_latest.apkg'));
tester.view.physicalSize = const Size(1400, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
cardGateway: cardGateway,
files: files,
assetServer: server,
),
location: RoutePaths.library,
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Import'));
await tester.pumpAndSettle();
await tester.tap(find.text('Anki deck'));
await tester.pump();
await tester.pump();
await tester.pump();
await tester.pumpAndSettle();
await tester.tap(find.text('Recreate as sub-studysets'));
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Import'));
await tester.pumpAndSettle();
final repository = StudysetRepository(studysets);
addTearDown(repository.dispose);
final all = await repository.all();
final japanese = all.singleWhere((each) => each.parentId == null);
expect(japanese.content.title, 'Japanese');
expect(
[
for (final each in all)
if (each.parentId == japanese.id) each.content.title,
],
['Animals'],
);
expect(find.text('Japanese'), findsOneWidget);
});
testWidgets("cancelling the Library's Import leaves no studyset behind", (
tester,
) async {
final studysets = FakeStudysetGateway();
files.next = (name: 'export.apkg', bytes: fixture('anki_latest.apkg'));
tester.view.physicalSize = const Size(1400, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(studysetGateway: studysets, files: files),
location: RoutePaths.library,
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Import'));
await tester.pumpAndSettle();
await tester.tap(find.text('Anki deck'));
await tester.pump();
await tester.pump();
await tester.pump();
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
final repository = StudysetRepository(studysets);
addTearDown(repository.dispose);
expect(await repository.all(), isEmpty);
});
testWidgets('shows the flashcards after a deck came into this studyset, '
'whatever tab was open', (tester) async {
await withNote();