feat(app): show added material in its studyset tab
An Anki import switches to Flashcards when the cards come into this studyset (flattened or tagged by subdeck), not when subdecks become sub-studysets; an imported path switches to Paths. Creating flashcards, a note or a path switches to its tab as well, seen on coming back. On opening, the studyset shows the first tab with content of its own: unfinished paths, then flashcards, then notes; Flashcards when it holds nothing. Its topics' content doesn't count, since the tabs list only the studyset's own. A tab tapped or swiped to by the person, or material added, stays. Add material is built both in the app bar and in the bottom bar, so a ContentTabCubit carries the requests to the tab controller, and importFromAnki now returns the mode it imported with. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
e4bfe3bdeb
commit
9324432f61
5 changed files with 276 additions and 26 deletions
|
|
@ -11,8 +11,9 @@ import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// Lets the person pick an Anki package (`.apkg`, or a whole collection's
|
/// 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], as they choose
|
||||||
/// in a sheet; then says what came over.
|
/// in a sheet; then says what came over. Completes with how its decks became
|
||||||
Future<void> importFromAnki(
|
/// studysets, or with null when nothing was imported.
|
||||||
|
Future<AnkiImportMode?> importFromAnki(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required String studysetId,
|
required String studysetId,
|
||||||
}) async {
|
}) async {
|
||||||
|
|
@ -23,7 +24,7 @@ Future<void> importFromAnki(
|
||||||
extensions: const ['apkg', 'colpkg'],
|
extensions: const ['apkg', 'colpkg'],
|
||||||
);
|
);
|
||||||
if (file == null || !context.mounted) {
|
if (file == null || !context.mounted) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
final done = await showAdaptiveSheet<AnkiImportStateDone>(
|
final done = await showAdaptiveSheet<AnkiImportStateDone>(
|
||||||
context,
|
context,
|
||||||
|
|
@ -49,7 +50,7 @@ Future<void> importFromAnki(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (done == null) {
|
if (done == null) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
final result = done.result;
|
final result = done.result;
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
|
|
@ -71,6 +72,7 @@ Future<void> importFromAnki(
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
return done.mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
class const _AnkiImport() extends StatelessWidget {
|
class const _AnkiImport() extends StatelessWidget {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
/// The tabs of a studyset's content, in their order.
|
||||||
|
enum StudysetTab() {
|
||||||
|
/// Its learning paths.
|
||||||
|
paths,
|
||||||
|
|
||||||
|
/// Its flashcards.
|
||||||
|
flashcards,
|
||||||
|
|
||||||
|
/// Its notes.
|
||||||
|
notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last request to show a content tab, if any; `serial` tells two
|
||||||
|
/// requests for one tab apart.
|
||||||
|
typedef ContentTabState = ({StudysetTab tab, int serial})?;
|
||||||
|
|
||||||
|
/// Asks the studyset screen to show a content tab: the one where material
|
||||||
|
/// just added or imported lands (decided 2026-10-03), so the person sees it
|
||||||
|
/// rather than looking for it.
|
||||||
|
class ContentTabCubit() extends Cubit<ContentTabState> {
|
||||||
|
this : super(null);
|
||||||
|
|
||||||
|
/// Shows [tab].
|
||||||
|
void show(StudysetTab tab) =>
|
||||||
|
emit((tab: tab, serial: (state?.serial ?? 0) + 1));
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:developer';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
@ -15,12 +17,14 @@ import 'package:halkyon_learn_flutter/src/core/widget/scroll_hiding_fab.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_importer.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
|
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_list_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_list_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_list.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_list.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/note/widget/note_list.dart';
|
import 'package:halkyon_learn_flutter/src/feature/note/widget/note_list.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/content_tab_cubit.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.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_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||||
|
|
@ -76,6 +80,8 @@ class const StudysetScreen({
|
||||||
studyset.id,
|
studyset.id,
|
||||||
)..add(const PathListEventStarted()),
|
)..add(const PathListEventStarted()),
|
||||||
),
|
),
|
||||||
|
// The content tab to show once material was added.
|
||||||
|
BlocProvider(create: (context) => ContentTabCubit()),
|
||||||
],
|
],
|
||||||
child: MultiBlocListener(
|
child: MultiBlocListener(
|
||||||
listeners: [
|
listeners: [
|
||||||
|
|
@ -95,6 +101,9 @@ class const StudysetScreen({
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final result = state.importResult!;
|
final result = state.importResult!;
|
||||||
|
if (result.failure == null) {
|
||||||
|
context.read<ContentTabCubit>().show(StudysetTab.paths);
|
||||||
|
}
|
||||||
final message = switch (result.failure) {
|
final message = switch (result.failure) {
|
||||||
final failure? => l10n.pathImportFailed(failure),
|
final failure? => l10n.pathImportFailed(failure),
|
||||||
null => [
|
null => [
|
||||||
|
|
@ -259,9 +268,14 @@ class const _StudysetView({
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The studyset's content, in tabs that appear as their features land:
|
/// The studyset's content, in tabs that appear as their features land:
|
||||||
/// learning paths, flashcards and notes for now. It opens on the flashcards,
|
/// learning paths, flashcards and notes for now.
|
||||||
/// and on the paths instead once it is known that some path isn't completed
|
///
|
||||||
/// yet, unless the person chose a tab meanwhile.
|
/// It opens on the first tab with something of the studyset's own in it
|
||||||
|
/// (its topics' content is in theirs), in their order: paths not completed
|
||||||
|
/// yet, flashcards, notes; on the flashcards when there is nothing (decided
|
||||||
|
/// 2026-10-03). Material added or imported shows in its own tab
|
||||||
|
/// ([ContentTabCubit]). Once the person chose a tab, or material was added,
|
||||||
|
/// opening no longer moves it.
|
||||||
class const _Content({required final String studysetId})
|
class const _Content({required final String studysetId})
|
||||||
extends StatefulWidget {
|
extends StatefulWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -271,13 +285,26 @@ class const _Content({required final String studysetId})
|
||||||
class _ContentState()
|
class _ContentState()
|
||||||
extends State<_Content>
|
extends State<_Content>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late final _tabs = TabController(length: 3, vsync: this, initialIndex: 1);
|
late final _tabs = TabController(
|
||||||
|
length: StudysetTab.values.length,
|
||||||
|
vsync: this,
|
||||||
|
initialIndex: StudysetTab.flashcards.index,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Whether the tab shown is settled: chosen on opening, by the person, or
|
||||||
|
/// for material added.
|
||||||
var _settled = false;
|
var _settled = false;
|
||||||
|
|
||||||
|
/// Whether the studyset itself holds cards and notes, read once on
|
||||||
|
/// opening; null until then.
|
||||||
|
({bool cards, bool notes})? _holds;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// Swiping to a tab is choosing it as much as tapping it is.
|
||||||
_tabs.addListener(() => _settled = true);
|
_tabs.addListener(() => _settled = true);
|
||||||
|
unawaited(_readWhatItHolds());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -286,18 +313,67 @@ class _ContentState()
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _readWhatItHolds() async {
|
||||||
|
final person = PersonScope.of(context);
|
||||||
|
try {
|
||||||
|
final cards = await person.cards.cardsOf(widget.studysetId);
|
||||||
|
final notes = await person.notes.countsByStudyset();
|
||||||
|
_holds = (
|
||||||
|
cards: cards.isNotEmpty,
|
||||||
|
notes: notes.containsKey(widget.studysetId),
|
||||||
|
);
|
||||||
|
} on Object catch (error) {
|
||||||
|
// It stays on the flashcards, as it opened.
|
||||||
|
log(
|
||||||
|
'Reading what a studyset holds failed',
|
||||||
|
name: 'Studyset',
|
||||||
|
error: error,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_chooseOnOpening();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _show(StudysetTab tab) {
|
||||||
|
_settled = true;
|
||||||
|
if (_tabs.index != tab.index) {
|
||||||
|
_tabs.animateTo(tab.index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chooses the tab to open on, once enough is known: paths in progress
|
||||||
|
/// win at once; the others once both are known.
|
||||||
|
void _chooseOnOpening() {
|
||||||
|
if (_settled || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final paths = context.read<PathListBloc>().state;
|
||||||
|
if (paths.completionKnown && paths.hasOpenPaths) {
|
||||||
|
_show(StudysetTab.paths);
|
||||||
|
} else if (paths.completionKnown && _holds != null) {
|
||||||
|
_show(switch (_holds!) {
|
||||||
|
(cards: false, notes: true) => StudysetTab.notes,
|
||||||
|
_ => StudysetTab.flashcards,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
return BlocListener<PathListBloc, PathListState>(
|
return MultiBlocListener(
|
||||||
listenWhen: (before, now) =>
|
listeners: [
|
||||||
!before.completionKnown && now.completionKnown,
|
BlocListener<ContentTabCubit, ContentTabState>(
|
||||||
listener: (context, state) {
|
listener: (context, request) {
|
||||||
if (!_settled && state.hasOpenPaths) {
|
if (request != null) {
|
||||||
_tabs.index = 0;
|
_show(request.tab);
|
||||||
}
|
}
|
||||||
_settled = true;
|
},
|
||||||
},
|
),
|
||||||
|
BlocListener<PathListBloc, PathListState>(
|
||||||
|
listener: (context, _) => _chooseOnOpening(),
|
||||||
|
),
|
||||||
|
],
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -306,9 +382,14 @@ class _ContentState()
|
||||||
isScrollable: true,
|
isScrollable: true,
|
||||||
tabAlignment: TabAlignment.start,
|
tabAlignment: TabAlignment.start,
|
||||||
tabs: [
|
tabs: [
|
||||||
Tab(text: l10n.studysetTabPaths),
|
for (final tab in StudysetTab.values)
|
||||||
Tab(text: l10n.studysetTabFlashcards),
|
Tab(
|
||||||
Tab(text: l10n.studysetTabNotes),
|
text: switch (tab) {
|
||||||
|
StudysetTab.paths => l10n.studysetTabPaths,
|
||||||
|
StudysetTab.flashcards => l10n.studysetTabFlashcards,
|
||||||
|
StudysetTab.notes => l10n.studysetTabNotes,
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -454,7 +535,9 @@ class const _StudyPill({
|
||||||
|
|
||||||
/// What can be added to the studyset, behind [builder]'s trigger:
|
/// What can be added to the studyset, behind [builder]'s trigger:
|
||||||
/// flashcards, notes and learning paths for now; files come with their
|
/// flashcards, notes and learning paths for now; files come with their
|
||||||
/// feature.
|
/// feature. What is added shows in its own tab ([ContentTabCubit]): what is
|
||||||
|
/// written there once the person comes back, what is imported once it is
|
||||||
|
/// in.
|
||||||
class const _AddMaterial({
|
class const _AddMaterial({
|
||||||
required final String studysetId,
|
required final String studysetId,
|
||||||
required final Widget Function(BuildContext context, MenuController menu)
|
required final Widget Function(BuildContext context, MenuController menu)
|
||||||
|
|
@ -467,19 +550,27 @@ class const _AddMaterial({
|
||||||
menuChildren: [
|
menuChildren: [
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
leadingIcon: const Icon(LucideIcons.plus),
|
leadingIcon: const Icon(LucideIcons.plus),
|
||||||
onPressed: () => context.push(RoutePaths.newCard(studysetId)),
|
onPressed: () {
|
||||||
|
context.read<ContentTabCubit>().show(StudysetTab.flashcards);
|
||||||
|
context.push(RoutePaths.newCard(studysetId));
|
||||||
|
},
|
||||||
child: Text(l10n.studysetCreateFlashcards),
|
child: Text(l10n.studysetCreateFlashcards),
|
||||||
),
|
),
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
leadingIcon: const Icon(LucideIcons.notebookPen),
|
leadingIcon: const Icon(LucideIcons.notebookPen),
|
||||||
onPressed: () => context.push(RoutePaths.newNote(studysetId)),
|
onPressed: () {
|
||||||
|
context.read<ContentTabCubit>().show(StudysetTab.notes);
|
||||||
|
context.push(RoutePaths.newNote(studysetId));
|
||||||
|
},
|
||||||
child: Text(l10n.studysetCreateNote),
|
child: Text(l10n.studysetCreateNote),
|
||||||
),
|
),
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
leadingIcon: const Icon(LucideIcons.route),
|
leadingIcon: const Icon(LucideIcons.route),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final bloc = context.read<PathListBloc>();
|
final bloc = context.read<PathListBloc>();
|
||||||
|
final tabs = context.read<ContentTabCubit>();
|
||||||
if (await askForNewPath(context) case final content?) {
|
if (await askForNewPath(context) case final content?) {
|
||||||
|
tabs.show(StudysetTab.paths);
|
||||||
bloc.add(PathListEventCreateRequested(content));
|
bloc.add(PathListEventCreateRequested(content));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -487,7 +578,17 @@ class const _AddMaterial({
|
||||||
),
|
),
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
leadingIcon: const Icon(LucideIcons.fileInput),
|
leadingIcon: const Icon(LucideIcons.fileInput),
|
||||||
onPressed: () => importFromAnki(context, studysetId: studysetId),
|
onPressed: () async {
|
||||||
|
final tabs = context.read<ContentTabCubit>();
|
||||||
|
// Cards that came into this studyset show in its flashcards;
|
||||||
|
// subdecks become topics instead.
|
||||||
|
switch (await importFromAnki(context, studysetId: studysetId)) {
|
||||||
|
case AnkiImportMode.flatten || AnkiImportMode.tagBySubdeck:
|
||||||
|
tabs.show(StudysetTab.flashcards);
|
||||||
|
case AnkiImportMode.subStudysets || null:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
child: Text(l10n.ankiImportMenu),
|
child: Text(l10n.ankiImportMenu),
|
||||||
),
|
),
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.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/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/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_payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
@ -10,6 +13,7 @@ import 'package:material_ui/material_ui.dart';
|
||||||
import '../../../support/fake_asset_server_gateway.dart';
|
import '../../../support/fake_asset_server_gateway.dart';
|
||||||
import '../../../support/fake_card_gateway.dart';
|
import '../../../support/fake_card_gateway.dart';
|
||||||
import '../../../support/fake_file_picker.dart';
|
import '../../../support/fake_file_picker.dart';
|
||||||
|
import '../../../support/fake_note_gateway.dart';
|
||||||
import '../../../support/fake_studyset_gateway.dart';
|
import '../../../support/fake_studyset_gateway.dart';
|
||||||
import '../../../support/pump_app.dart';
|
import '../../../support/pump_app.dart';
|
||||||
import '../../../support/test_dependencies.dart';
|
import '../../../support/test_dependencies.dart';
|
||||||
|
|
@ -19,13 +23,30 @@ void main() {
|
||||||
late FakeCardGateway cardGateway;
|
late FakeCardGateway cardGateway;
|
||||||
late FakeFilePicker files;
|
late FakeFilePicker files;
|
||||||
late FakeAssetServerGateway server;
|
late FakeAssetServerGateway server;
|
||||||
|
late FakeNoteGateway noteGateway;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
cardGateway = FakeCardGateway();
|
cardGateway = FakeCardGateway();
|
||||||
files = FakeFilePicker();
|
files = FakeFilePicker();
|
||||||
server = FakeAssetServerGateway();
|
server = FakeAssetServerGateway();
|
||||||
|
noteGateway = FakeNoteGateway();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// The index of the studyset's content tab shown: 0 paths, 1 flashcards,
|
||||||
|
/// 2 notes.
|
||||||
|
int shownTab(WidgetTester tester) =>
|
||||||
|
tester.widget<TabBar>(find.byType(TabBar)).controller!.index;
|
||||||
|
|
||||||
|
/// A note in the studyset, so that it opens on its notes.
|
||||||
|
Future<void> withNote() async {
|
||||||
|
final notes = NoteRepository(noteGateway);
|
||||||
|
addTearDown(notes.dispose);
|
||||||
|
await notes.create(
|
||||||
|
'japanese',
|
||||||
|
NoteContent(title: 'Particles', body: RichContent.empty()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Opens the studyset Japanese, in a window wide enough for Add material
|
/// Opens the studyset Japanese, in a window wide enough for Add material
|
||||||
/// to be a button.
|
/// to be a button.
|
||||||
Future<void> showStudyset(WidgetTester tester) async {
|
Future<void> showStudyset(WidgetTester tester) async {
|
||||||
|
|
@ -41,11 +62,15 @@ void main() {
|
||||||
encodeStudysetPayload(StudysetContent.titled('Japanese')),
|
encodeStudysetPayload(StudysetContent.titled('Japanese')),
|
||||||
),
|
),
|
||||||
cardGateway: cardGateway,
|
cardGateway: cardGateway,
|
||||||
|
noteGateway: noteGateway,
|
||||||
files: files,
|
files: files,
|
||||||
assetServer: server,
|
assetServer: server,
|
||||||
),
|
),
|
||||||
location: RoutePaths.studyset('japanese'),
|
location: RoutePaths.studyset('japanese'),
|
||||||
);
|
);
|
||||||
|
// Lets the lists settle, and the screen choose its first tab.
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Chooses Import from Anki under Add material.
|
/// Chooses Import from Anki under Add material.
|
||||||
|
|
@ -90,6 +115,33 @@ void main() {
|
||||||
expect(await cards.cardsOf('japanese'), hasLength(4));
|
expect(await cards.cardsOf('japanese'), hasLength(4));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('shows the flashcards after a deck came into this studyset, '
|
||||||
|
'whatever tab was open', (tester) async {
|
||||||
|
await withNote();
|
||||||
|
await startImport(tester, fixture('anki_latest.apkg'));
|
||||||
|
await tester.tap(find.text('Tag cards by subdeck'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Import'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(shownTab(tester), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('leaves the tab as it was when the subdecks became '
|
||||||
|
'sub-studysets', (tester) async {
|
||||||
|
await withNote();
|
||||||
|
files.next = (name: 'japanese.apkg', bytes: fixture('anki_latest.apkg'));
|
||||||
|
await showStudyset(tester);
|
||||||
|
expect(shownTab(tester), 2);
|
||||||
|
await chooseImport(tester);
|
||||||
|
await tester.tap(find.text('Recreate as sub-studysets'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Import'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(shownTab(tester), 2);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('warns when the files may not fit in the room left', (
|
testWidgets('warns when the files may not fit in the room left', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,15 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_pa
|
||||||
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.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.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_payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
import '../../../support/fake_file_picker.dart';
|
import '../../../support/fake_file_picker.dart';
|
||||||
import '../../../support/fake_learning_path_gateway.dart';
|
import '../../../support/fake_learning_path_gateway.dart';
|
||||||
|
import '../../../support/fake_note_gateway.dart';
|
||||||
import '../../../support/fake_path_answer_gateway.dart';
|
import '../../../support/fake_path_answer_gateway.dart';
|
||||||
import '../../../support/fake_studyset_gateway.dart';
|
import '../../../support/fake_studyset_gateway.dart';
|
||||||
import '../../../support/pump_app.dart';
|
import '../../../support/pump_app.dart';
|
||||||
|
|
@ -23,6 +26,7 @@ void main() {
|
||||||
late FakeLearningPathGateway pathGateway;
|
late FakeLearningPathGateway pathGateway;
|
||||||
late FakePathAnswerGateway answers;
|
late FakePathAnswerGateway answers;
|
||||||
late FakeFilePicker files;
|
late FakeFilePicker files;
|
||||||
|
late FakeNoteGateway noteGateway;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
studysets = FakeStudysetGateway()
|
studysets = FakeStudysetGateway()
|
||||||
|
|
@ -33,8 +37,14 @@ void main() {
|
||||||
pathGateway = FakeLearningPathGateway();
|
pathGateway = FakeLearningPathGateway();
|
||||||
answers = FakePathAnswerGateway();
|
answers = FakePathAnswerGateway();
|
||||||
files = FakeFilePicker();
|
files = FakeFilePicker();
|
||||||
|
noteGateway = FakeNoteGateway();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// The index of the studyset's content tab shown: 0 paths, 1 flashcards,
|
||||||
|
/// 2 notes.
|
||||||
|
int shownTab(WidgetTester tester) =>
|
||||||
|
tester.widget<TabBar>(find.byType(TabBar)).controller!.index;
|
||||||
|
|
||||||
Future<void> showApp(WidgetTester tester, String location) async {
|
Future<void> showApp(WidgetTester tester, String location) async {
|
||||||
tester.view.physicalSize = const Size(1200, 1000);
|
tester.view.physicalSize = const Size(1200, 1000);
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
|
|
@ -45,6 +55,7 @@ void main() {
|
||||||
studysetGateway: studysets,
|
studysetGateway: studysets,
|
||||||
pathGateway: pathGateway,
|
pathGateway: pathGateway,
|
||||||
answerGateway: answers,
|
answerGateway: answers,
|
||||||
|
noteGateway: noteGateway,
|
||||||
files: files,
|
files: files,
|
||||||
),
|
),
|
||||||
location: location,
|
location: location,
|
||||||
|
|
@ -92,6 +103,39 @@ void main() {
|
||||||
expect(find.text('Particles'), findsOneWidget);
|
expect(find.text('Particles'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('a studyset with only notes opens on them', (tester) async {
|
||||||
|
final notes = NoteRepository(noteGateway);
|
||||||
|
addTearDown(notes.dispose);
|
||||||
|
await notes.create(
|
||||||
|
'japanese',
|
||||||
|
NoteContent(title: 'Particles', body: RichContent.empty()),
|
||||||
|
);
|
||||||
|
|
||||||
|
await showApp(tester, RoutePaths.studyset('japanese'));
|
||||||
|
|
||||||
|
expect(shownTab(tester), 2);
|
||||||
|
expect(find.text('Particles'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets("notes only in its topics don't open the studyset on its own "
|
||||||
|
'empty notes', (tester) async {
|
||||||
|
studysets.receiveRemoteChange(
|
||||||
|
'grammar',
|
||||||
|
encodeStudysetPayload(StudysetContent.titled('Grammar')),
|
||||||
|
parentId: 'japanese',
|
||||||
|
);
|
||||||
|
final notes = NoteRepository(noteGateway);
|
||||||
|
addTearDown(notes.dispose);
|
||||||
|
await notes.create(
|
||||||
|
'grammar',
|
||||||
|
NoteContent(title: 'Particles', body: RichContent.empty()),
|
||||||
|
);
|
||||||
|
|
||||||
|
await showApp(tester, RoutePaths.studyset('japanese'));
|
||||||
|
|
||||||
|
expect(shownTab(tester), 1);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('without paths, the studyset opens on its flashcards', (
|
testWidgets('without paths, the studyset opens on its flashcards', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
@ -156,11 +200,34 @@ void main() {
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(find.textContaining('1 block was left out'), findsOneWidget);
|
expect(find.textContaining('1 block was left out'), findsOneWidget);
|
||||||
await tester.tap(find.text('Paths'));
|
// The paths show by themselves.
|
||||||
await tester.pumpAndSettle();
|
expect(shownTab(tester), 0);
|
||||||
expect(find.text('Particles'), findsOneWidget);
|
expect(find.text('Particles'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('a note written from Add material shows in the Notes tab on '
|
||||||
|
'coming back, as flashcards do in theirs', (tester) async {
|
||||||
|
final paths = repository();
|
||||||
|
final id = await paths.createPath(
|
||||||
|
'japanese',
|
||||||
|
LearningPathContent.titled('Particles'),
|
||||||
|
);
|
||||||
|
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
|
||||||
|
await showApp(tester, RoutePaths.studyset('japanese'));
|
||||||
|
expect(shownTab(tester), 0);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Add material'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await chooseInMenu(tester, 'Create a note');
|
||||||
|
await tester.enterText(find.byType(TextField), 'Topic or subject');
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pageBack();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(shownTab(tester), 2);
|
||||||
|
expect(find.text('Topic or subject'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('works through a path: an info step, a choice answered wrong '
|
testWidgets('works through a path: an info step, a choice answered wrong '
|
||||||
'then right, and the finish', (tester) async {
|
'then right, and the finish', (tester) async {
|
||||||
final paths = repository();
|
final paths = repository();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue