feat(app): show added material in its studyset tab
All checks were successful
CI / check (push) Successful in 8m5s
CI / deploy (push) Successful in 1m7s

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:
Tizian 「ティツィアーン」 2026-10-03 10:46:08 +02:00
commit 9324432f61
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 276 additions and 26 deletions

View file

@ -11,8 +11,9 @@ 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
/// in a sheet; then says what came over.
Future<void> importFromAnki(
/// 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,
}) async {
@ -23,7 +24,7 @@ Future<void> importFromAnki(
extensions: const ['apkg', 'colpkg'],
);
if (file == null || !context.mounted) {
return;
return null;
}
final done = await showAdaptiveSheet<AnkiImportStateDone>(
context,
@ -49,7 +50,7 @@ Future<void> importFromAnki(
},
);
if (done == null) {
return;
return null;
}
final result = done.result;
messenger.showSnackBar(
@ -71,6 +72,7 @@ Future<void> importFromAnki(
),
),
);
return done.mode;
}
class const _AnkiImport() extends StatelessWidget {

View file

@ -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));
}

View file

@ -1,4 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:math' as math;
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/title_with_kicker.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/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/widget/path_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/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/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
@ -76,6 +80,8 @@ class const StudysetScreen({
studyset.id,
)..add(const PathListEventStarted()),
),
// The content tab to show once material was added.
BlocProvider(create: (context) => ContentTabCubit()),
],
child: MultiBlocListener(
listeners: [
@ -95,6 +101,9 @@ class const StudysetScreen({
listener: (context, state) {
final l10n = AppLocalizations.of(context);
final result = state.importResult!;
if (result.failure == null) {
context.read<ContentTabCubit>().show(StudysetTab.paths);
}
final message = switch (result.failure) {
final failure? => l10n.pathImportFailed(failure),
null => [
@ -259,9 +268,14 @@ class const _StudysetView({
}
/// The studyset's content, in tabs that appear as their features land:
/// learning paths, flashcards and notes for now. It opens on the flashcards,
/// and on the paths instead once it is known that some path isn't completed
/// yet, unless the person chose a tab meanwhile.
/// learning paths, flashcards and notes for now.
///
/// 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})
extends StatefulWidget {
@override
@ -271,13 +285,26 @@ class const _Content({required final String studysetId})
class _ContentState()
extends State<_Content>
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;
/// Whether the studyset itself holds cards and notes, read once on
/// opening; null until then.
({bool cards, bool notes})? _holds;
@override
void initState() {
super.initState();
// Swiping to a tab is choosing it as much as tapping it is.
_tabs.addListener(() => _settled = true);
unawaited(_readWhatItHolds());
}
@override
@ -286,18 +313,67 @@ class _ContentState()
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
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return BlocListener<PathListBloc, PathListState>(
listenWhen: (before, now) =>
!before.completionKnown && now.completionKnown,
listener: (context, state) {
if (!_settled && state.hasOpenPaths) {
_tabs.index = 0;
}
_settled = true;
},
return MultiBlocListener(
listeners: [
BlocListener<ContentTabCubit, ContentTabState>(
listener: (context, request) {
if (request != null) {
_show(request.tab);
}
},
),
BlocListener<PathListBloc, PathListState>(
listener: (context, _) => _chooseOnOpening(),
),
],
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@ -306,9 +382,14 @@ class _ContentState()
isScrollable: true,
tabAlignment: TabAlignment.start,
tabs: [
Tab(text: l10n.studysetTabPaths),
Tab(text: l10n.studysetTabFlashcards),
Tab(text: l10n.studysetTabNotes),
for (final tab in StudysetTab.values)
Tab(
text: switch (tab) {
StudysetTab.paths => l10n.studysetTabPaths,
StudysetTab.flashcards => l10n.studysetTabFlashcards,
StudysetTab.notes => l10n.studysetTabNotes,
},
),
],
),
Expanded(
@ -454,7 +535,9 @@ class const _StudyPill({
/// What can be added to the studyset, behind [builder]'s trigger:
/// 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({
required final String studysetId,
required final Widget Function(BuildContext context, MenuController menu)
@ -467,19 +550,27 @@ class const _AddMaterial({
menuChildren: [
MenuItemButton(
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),
),
MenuItemButton(
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),
),
MenuItemButton(
leadingIcon: const Icon(LucideIcons.route),
onPressed: () async {
final bloc = context.read<PathListBloc>();
final tabs = context.read<ContentTabCubit>();
if (await askForNewPath(context) case final content?) {
tabs.show(StudysetTab.paths);
bloc.add(PathListEventCreateRequested(content));
}
},
@ -487,7 +578,17 @@ class const _AddMaterial({
),
MenuItemButton(
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),
),
MenuItemButton(

View file

@ -1,8 +1,11 @@
import 'dart:typed_data';
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/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/model/studyset_content.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_card_gateway.dart';
import '../../../support/fake_file_picker.dart';
import '../../../support/fake_note_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
@ -19,13 +23,30 @@ void main() {
late FakeCardGateway cardGateway;
late FakeFilePicker files;
late FakeAssetServerGateway server;
late FakeNoteGateway noteGateway;
setUp(() {
cardGateway = FakeCardGateway();
files = FakeFilePicker();
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
/// to be a button.
Future<void> showStudyset(WidgetTester tester) async {
@ -41,11 +62,15 @@ void main() {
encodeStudysetPayload(StudysetContent.titled('Japanese')),
),
cardGateway: cardGateway,
noteGateway: noteGateway,
files: files,
assetServer: server,
),
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.
@ -90,6 +115,33 @@ void main() {
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', (
tester,
) async {

View file

@ -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/learning_path.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/model/studyset_content.dart';
import 'package:material_ui/material_ui.dart';
import '../../../support/fake_file_picker.dart';
import '../../../support/fake_learning_path_gateway.dart';
import '../../../support/fake_note_gateway.dart';
import '../../../support/fake_path_answer_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/pump_app.dart';
@ -23,6 +26,7 @@ void main() {
late FakeLearningPathGateway pathGateway;
late FakePathAnswerGateway answers;
late FakeFilePicker files;
late FakeNoteGateway noteGateway;
setUp(() {
studysets = FakeStudysetGateway()
@ -33,8 +37,14 @@ void main() {
pathGateway = FakeLearningPathGateway();
answers = FakePathAnswerGateway();
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 {
tester.view.physicalSize = const Size(1200, 1000);
tester.view.devicePixelRatio = 1;
@ -45,6 +55,7 @@ void main() {
studysetGateway: studysets,
pathGateway: pathGateway,
answerGateway: answers,
noteGateway: noteGateway,
files: files,
),
location: location,
@ -92,6 +103,39 @@ void main() {
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', (
tester,
) async {
@ -156,11 +200,34 @@ void main() {
findsOneWidget,
);
expect(find.textContaining('1 block was left out'), findsOneWidget);
await tester.tap(find.text('Paths'));
await tester.pumpAndSettle();
// The paths show by themselves.
expect(shownTab(tester), 0);
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 '
'then right, and the finish', (tester) async {
final paths = repository();