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:
parent
18c1370bf6
commit
a26150a314
10 changed files with 190 additions and 9 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue