feat(studyset): a studyset opens on the tab it was left on
A studyset opened on its flashcards and slid over to its paths once it had worked out that one was unfinished, every time it was opened. It now opens on the tab it was left on, on this device: kept in the person's folder (studyset_tabs.json), read as their data opens, so at once. The first time a studyset opens here it still chooses by what it holds (paths in progress, then flashcards, notes, files), but shows that it is loading until it knows, rather than one tab and then another; paths that can't be read count as none. 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
986252fea4
commit
6b1ff4122e
9 changed files with 393 additions and 145 deletions
|
|
@ -74,6 +74,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.d
|
|||
import 'package:halkyon_learn_flutter/src/feature/study/data/sync_review_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/sync_studyset_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/sync_tag_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
|
|
@ -364,6 +365,10 @@ Future<PersonDependencies> _openPerson(
|
|||
ServerpodPublishGateway(client),
|
||||
FilePublishedStore(File(p.join(folder.path, 'published.json'))),
|
||||
);
|
||||
final studysetTabs = StudysetTabs(
|
||||
FileStudysetTabStore(File(p.join(folder.path, 'studyset_tabs.json'))),
|
||||
);
|
||||
await studysetTabs.open();
|
||||
|
||||
return PersonDependencies(
|
||||
sync: sync,
|
||||
|
|
@ -386,6 +391,7 @@ Future<PersonDependencies> _openPerson(
|
|||
pomodoroRuns: FilePomodoroRunStore(
|
||||
File(p.join(folder.path, 'pomodoro.json')),
|
||||
),
|
||||
studysetTabs: studysetTabs,
|
||||
profile: ProfileRepository(ServerpodProfileGateway(client)),
|
||||
publishing: publishing,
|
||||
explore: ExploreRepository(
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_reposit
|
|||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
|
||||
/// Everything that belongs to one signed-in person: their local database, its
|
||||
|
|
@ -80,6 +81,9 @@ class PersonDependencies({
|
|||
/// Where this device keeps the person's Pomodoro timer.
|
||||
required final PomodoroRunStore pomodoroRuns,
|
||||
|
||||
/// The tab each of the person's studysets was left on, on this device.
|
||||
required final StudysetTabs studysetTabs,
|
||||
|
||||
/// The person's public profile, on the instance.
|
||||
required final ProfileRepository profile,
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
|
||||
|
||||
/// Where the device keeps the tab each of the person's studysets was left
|
||||
/// on.
|
||||
abstract interface class StudysetTabStore() {
|
||||
/// The tabs kept, by studyset id; none when nothing is or it can't be
|
||||
/// read.
|
||||
Future<Map<String, String>> read();
|
||||
|
||||
/// Keeps [tabs].
|
||||
Future<void> write(Map<String, String> tabs);
|
||||
}
|
||||
|
||||
/// Keeps them as a JSON object in [_file], in the person's folder, which
|
||||
/// goes with them; written to a temporary file first and renamed into
|
||||
/// place.
|
||||
class FileStudysetTabStore(final File _file) implements StudysetTabStore {
|
||||
// One write at a time: they share the temporary file.
|
||||
final _queue = SerialQueue();
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> read() async {
|
||||
try {
|
||||
final json = jsonDecode(await _file.readAsString()) as Map;
|
||||
return {
|
||||
for (final MapEntry(:key, :value) in json.entries)
|
||||
if ((key, value) case (final String id, final String tab)) id: tab,
|
||||
};
|
||||
} on FileSystemException {
|
||||
return const {};
|
||||
} on Object catch (error) {
|
||||
log(
|
||||
"The studysets' tabs are unreadable",
|
||||
name: 'Studyset',
|
||||
error: error,
|
||||
);
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(Map<String, String> tabs) => _queue.run(() async {
|
||||
await _file.parent.create(recursive: true);
|
||||
final temporary = File('${_file.path}.new');
|
||||
await temporary.writeAsString(jsonEncode(tabs), flush: true);
|
||||
await temporary.rename(_file.path);
|
||||
});
|
||||
}
|
||||
|
||||
/// The tab each of the person's studysets was left on, on this device
|
||||
/// (decided 2026-10-11), so a studyset opens again where it was left rather
|
||||
/// than choosing anew each time: read once as the person's data opens,
|
||||
/// kept as tabs are shown. A tab is kept by its name; one the app no longer
|
||||
/// has reads as none.
|
||||
class StudysetTabs(final StudysetTabStore _store) {
|
||||
var _tabs = <String, String>{};
|
||||
|
||||
/// Reads the tabs kept, before any studyset opens.
|
||||
Future<void> open() async => _tabs = {...await _store.read()};
|
||||
|
||||
/// The tab the studyset [studysetId] was left on; null if it never opened
|
||||
/// here.
|
||||
String? of(String studysetId) => _tabs[studysetId];
|
||||
|
||||
/// Keeps [tab] as the one the studyset [studysetId] was left on; done once
|
||||
/// it is written.
|
||||
Future<void> keep(String studysetId, String tab) async {
|
||||
if (_tabs[studysetId] == tab) {
|
||||
return;
|
||||
}
|
||||
_tabs = {..._tabs, studysetId: tab};
|
||||
await _store.write(_tabs);
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_and_wait.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/theme/brand.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/theme/palette_color.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/reorder_toggle.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/scroll_hiding_fab.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
||||
|
|
@ -54,6 +55,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dar
|
|||
import 'package:halkyon_learn_flutter/src/feature/study/widget/start_studying.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/data/studyset_tabs.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/widget/delete_studyset.dart';
|
||||
|
|
@ -605,12 +607,15 @@ class const _BibliographyButton({required final Studyset studyset})
|
|||
/// narrowing them to a tag and managing tags, on the paths reordering them
|
||||
/// once there are two.
|
||||
///
|
||||
/// 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, files; 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.
|
||||
/// It opens on the tab it was left on, on this device ([StudysetTabs],
|
||||
/// decided 2026-10-11). Opened here for the first time, 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, files; on the flashcards when there is nothing
|
||||
/// (decided 2026-10-03); until it knows which, it shows that it is loading
|
||||
/// rather than one tab and then another. 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
|
||||
|
|
@ -623,11 +628,15 @@ class _ContentState()
|
|||
late final _tabs = TabController(
|
||||
length: StudysetTab.values.length,
|
||||
vsync: this,
|
||||
initialIndex: StudysetTab.flashcards.index,
|
||||
initialIndex: _active,
|
||||
);
|
||||
|
||||
/// Whether the tab shown is settled: chosen on opening, by the person, or
|
||||
/// for material added.
|
||||
/// The tab each studyset was left on, on this device.
|
||||
late final StudysetTabs _kept = PersonScope.of(context).studysetTabs;
|
||||
|
||||
/// Whether the tab shown is settled: the one it was left on, chosen on
|
||||
/// opening, by the person, or for material added. Until then, no tab
|
||||
/// shows.
|
||||
var _settled = false;
|
||||
|
||||
/// Whether the paths show handles to drag them.
|
||||
|
|
@ -652,14 +661,30 @@ class _ContentState()
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (StudysetTab.values.asNameMap()[_kept.of(widget.studysetId)]
|
||||
case final tab?) {
|
||||
_active = tab.index;
|
||||
_settled = true;
|
||||
}
|
||||
_tabs.addListener(() {
|
||||
// Swiping to a tab is choosing it as much as tapping it is.
|
||||
_settled = true;
|
||||
_settle();
|
||||
if (!_tabs.indexIsChanging) {
|
||||
_active = _tabs.index;
|
||||
unawaited(
|
||||
_kept.keep(widget.studysetId, StudysetTab.values[_active].name),
|
||||
);
|
||||
}
|
||||
});
|
||||
unawaited(_readWhatItHolds());
|
||||
if (!_settled) {
|
||||
unawaited(_readWhatItHolds());
|
||||
}
|
||||
}
|
||||
|
||||
void _settle() {
|
||||
if (!_settled && mounted) {
|
||||
setState(() => _settled = true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -695,21 +720,30 @@ class _ContentState()
|
|||
pdfs: pdfs.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,
|
||||
);
|
||||
if (mounted && !_settled) {
|
||||
_show(StudysetTab.flashcards, opening: true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_chooseOnOpening();
|
||||
}
|
||||
|
||||
void _show(StudysetTab tab) {
|
||||
_settled = true;
|
||||
/// Shows [tab]: at once while [opening], sliding over otherwise.
|
||||
void _show(StudysetTab tab, {bool opening = false}) {
|
||||
_settle();
|
||||
if (_tabs.index != tab.index) {
|
||||
_tabs.animateTo(tab.index);
|
||||
if (opening) {
|
||||
_tabs.index = tab.index;
|
||||
} else {
|
||||
_tabs.animateTo(tab.index);
|
||||
}
|
||||
} else {
|
||||
unawaited(_kept.keep(widget.studysetId, tab.name));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -720,14 +754,16 @@ class _ContentState()
|
|||
return;
|
||||
}
|
||||
final paths = context.read<PathListBloc>().state;
|
||||
// Paths that can't be read are as good as none.
|
||||
final pathsKnown = paths.completionKnown || paths.failed;
|
||||
if (paths.completionKnown && paths.hasOpenPaths) {
|
||||
_show(StudysetTab.paths);
|
||||
} else if (paths.completionKnown && _holds != null) {
|
||||
_show(StudysetTab.paths, opening: true);
|
||||
} else if (pathsKnown && _holds != null) {
|
||||
_show(switch (_holds!) {
|
||||
(cards: false, notes: true, pdfs: _) => StudysetTab.notes,
|
||||
(cards: false, notes: false, pdfs: true) => StudysetTab.files,
|
||||
_ => StudysetTab.flashcards,
|
||||
});
|
||||
}, opening: true);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -783,148 +819,150 @@ class _ContentState()
|
|||
},
|
||||
),
|
||||
],
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) => Row(
|
||||
child: !_settled
|
||||
? const Center(child: AppLoadingIndicator())
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TabBar(
|
||||
controller: _tabs,
|
||||
onTap: _tapped,
|
||||
isScrollable: true,
|
||||
tabAlignment: TabAlignment.start,
|
||||
indicatorSize: TabBarIndicatorSize.label,
|
||||
// The line below runs the card's whole width instead.
|
||||
dividerColor: Colors.transparent,
|
||||
tabs: [
|
||||
for (final tab in StudysetTab.values)
|
||||
Tab(
|
||||
text: switch (tab) {
|
||||
StudysetTab.paths => l10n.studysetTabPaths,
|
||||
StudysetTab.flashcards =>
|
||||
l10n.studysetTabFlashcards,
|
||||
StudysetTab.notes => l10n.studysetTabNotes,
|
||||
StudysetTab.files => l10n.studysetTabFiles,
|
||||
},
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TabBar(
|
||||
controller: _tabs,
|
||||
onTap: _tapped,
|
||||
isScrollable: true,
|
||||
tabAlignment: TabAlignment.start,
|
||||
indicatorSize: TabBarIndicatorSize.label,
|
||||
// The line below runs the card's whole width instead.
|
||||
dividerColor: Colors.transparent,
|
||||
tabs: [
|
||||
for (final tab in StudysetTab.values)
|
||||
Tab(
|
||||
text: switch (tab) {
|
||||
StudysetTab.paths => l10n.studysetTabPaths,
|
||||
StudysetTab.flashcards =>
|
||||
l10n.studysetTabFlashcards,
|
||||
StudysetTab.notes => l10n.studysetTabNotes,
|
||||
StudysetTab.files => l10n.studysetTabFiles,
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: _tabs,
|
||||
builder: (context, _) => switch (StudysetTab
|
||||
.values[_tabs.index]) {
|
||||
StudysetTab.flashcards => Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_Browse(
|
||||
studysetId: widget.studysetId,
|
||||
labelled: constraints.maxWidth >= 480,
|
||||
),
|
||||
const CardListToolbar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
StudysetTab.paths =>
|
||||
BlocSelector<PathListBloc, PathListState, int>(
|
||||
selector: (state) => state.paths.length,
|
||||
builder: (context, count) => _ReorderBeside(
|
||||
count: count,
|
||||
reordering: _reorderingPaths,
|
||||
// Below that, an icon alone.
|
||||
labelled: constraints.maxWidth >= 480,
|
||||
tooltip: l10n.studysetReorderPaths,
|
||||
onToggle: () => setState(
|
||||
() => _reorderingPaths = !_reorderingPaths,
|
||||
),
|
||||
),
|
||||
),
|
||||
StudysetTab.notes =>
|
||||
BlocSelector<NoteListBloc, NoteListState, int>(
|
||||
selector: (state) => state.notes.length,
|
||||
builder: (context, count) => _ReorderBeside(
|
||||
count: count,
|
||||
reordering: _reorderingNotes,
|
||||
labelled: constraints.maxWidth >= 480,
|
||||
tooltip: l10n.studysetReorderNotes,
|
||||
onToggle: () => setState(
|
||||
() => _reorderingNotes = !_reorderingNotes,
|
||||
),
|
||||
),
|
||||
),
|
||||
StudysetTab.files =>
|
||||
BlocSelector<PdfListBloc, PdfListState, int>(
|
||||
selector: (state) => state.rows.length,
|
||||
builder: (context, count) => _ReorderBeside(
|
||||
count: count,
|
||||
reordering: _reorderingFiles,
|
||||
labelled: constraints.maxWidth >= 480,
|
||||
tooltip: l10n.studysetReorderFiles,
|
||||
onToggle: () {
|
||||
if (!_reorderingFiles) {
|
||||
// What is dragged is the person's own
|
||||
// order, whatever the files were sorted by.
|
||||
context.read<PdfListBloc>().add(
|
||||
const PdfListEventSorted(PdfSort.custom),
|
||||
);
|
||||
}
|
||||
setState(
|
||||
() => _reorderingFiles = !_reorderingFiles,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: _tabs,
|
||||
builder: (context, _) =>
|
||||
switch (StudysetTab.values[_tabs.index]) {
|
||||
StudysetTab.flashcards => Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_Browse(
|
||||
studysetId: widget.studysetId,
|
||||
labelled: constraints.maxWidth >= 480,
|
||||
),
|
||||
const CardListToolbar(),
|
||||
],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabs,
|
||||
children: [
|
||||
_tab(
|
||||
StudysetTab.paths,
|
||||
SliverPathList(
|
||||
studysetId: widget.studysetId,
|
||||
reordering: _reorderingPaths,
|
||||
padding: _listPadding,
|
||||
),
|
||||
StudysetTab.paths =>
|
||||
BlocSelector<PathListBloc, PathListState, int>(
|
||||
selector: (state) => state.paths.length,
|
||||
builder: (context, count) => _ReorderBeside(
|
||||
count: count,
|
||||
reordering: _reorderingPaths,
|
||||
// Below that, an icon alone.
|
||||
labelled: constraints.maxWidth >= 480,
|
||||
tooltip: l10n.studysetReorderPaths,
|
||||
onToggle: () => setState(
|
||||
() => _reorderingPaths = !_reorderingPaths,
|
||||
),
|
||||
),
|
||||
),
|
||||
StudysetTab.notes =>
|
||||
BlocSelector<NoteListBloc, NoteListState, int>(
|
||||
selector: (state) => state.notes.length,
|
||||
builder: (context, count) => _ReorderBeside(
|
||||
count: count,
|
||||
reordering: _reorderingNotes,
|
||||
labelled: constraints.maxWidth >= 480,
|
||||
tooltip: l10n.studysetReorderNotes,
|
||||
onToggle: () => setState(
|
||||
() => _reorderingNotes = !_reorderingNotes,
|
||||
),
|
||||
),
|
||||
),
|
||||
StudysetTab.files =>
|
||||
BlocSelector<PdfListBloc, PdfListState, int>(
|
||||
selector: (state) => state.rows.length,
|
||||
builder: (context, count) => _ReorderBeside(
|
||||
count: count,
|
||||
reordering: _reorderingFiles,
|
||||
labelled: constraints.maxWidth >= 480,
|
||||
tooltip: l10n.studysetReorderFiles,
|
||||
onToggle: () {
|
||||
if (!_reorderingFiles) {
|
||||
// What is dragged is the person's own
|
||||
// order, whatever the files were sorted by.
|
||||
context.read<PdfListBloc>().add(
|
||||
const PdfListEventSorted(PdfSort.custom),
|
||||
);
|
||||
}
|
||||
setState(
|
||||
() => _reorderingFiles = !_reorderingFiles,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabs,
|
||||
children: [
|
||||
_tab(
|
||||
StudysetTab.paths,
|
||||
SliverPathList(
|
||||
studysetId: widget.studysetId,
|
||||
reordering: _reorderingPaths,
|
||||
padding: _listPadding,
|
||||
),
|
||||
),
|
||||
_tab(
|
||||
StudysetTab.flashcards,
|
||||
SliverCardList(
|
||||
cards: PersonScope.of(context).cards,
|
||||
studysetId: widget.studysetId,
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 96),
|
||||
),
|
||||
),
|
||||
_tab(
|
||||
StudysetTab.notes,
|
||||
SliverNoteList(
|
||||
studysetId: widget.studysetId,
|
||||
reordering: _reorderingNotes,
|
||||
padding: _listPadding,
|
||||
),
|
||||
),
|
||||
_tab(
|
||||
StudysetTab.files,
|
||||
SliverPdfList(
|
||||
studysetId: widget.studysetId,
|
||||
reordering: _reorderingFiles,
|
||||
padding: _listPadding,
|
||||
),
|
||||
_tab(
|
||||
StudysetTab.flashcards,
|
||||
SliverCardList(
|
||||
cards: PersonScope.of(context).cards,
|
||||
studysetId: widget.studysetId,
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 96),
|
||||
),
|
||||
),
|
||||
_tab(
|
||||
StudysetTab.notes,
|
||||
SliverNoteList(
|
||||
studysetId: widget.studysetId,
|
||||
reordering: _reorderingNotes,
|
||||
padding: _listPadding,
|
||||
),
|
||||
),
|
||||
_tab(
|
||||
StudysetTab.files,
|
||||
SliverPdfList(
|
||||
studysetId: widget.studysetId,
|
||||
reordering: _reorderingFiles,
|
||||
padding: _listPadding,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_reposit
|
|||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
|
|
@ -46,6 +47,7 @@ import '../../support/fake_tag_gateway.dart';
|
|||
import '../../support/memory_asset_file_cache.dart';
|
||||
import '../../support/memory_pomodoro_run_store.dart';
|
||||
import '../../support/memory_published_store.dart';
|
||||
import '../../support/memory_studyset_tab_store.dart';
|
||||
import '../../support/test_dependencies.dart';
|
||||
|
||||
/// A person's dependencies on fakes, closing with [onDispose].
|
||||
|
|
@ -80,6 +82,7 @@ PersonDependencies personDependencies({Future<void> Function()? onDispose}) {
|
|||
stepDeletions: StepDeletions(paths),
|
||||
pdfDeletions: PdfDeletions(pdfSources),
|
||||
pomodoroRuns: MemoryPomodoroRunStore(),
|
||||
studysetTabs: StudysetTabs(MemoryStudysetTabStore()),
|
||||
profile: ProfileRepository(FakeProfileGateway()),
|
||||
publishing: PublishRepository(
|
||||
FakePublishGateway(),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/confetti_
|
|||
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_tabs.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
|
|
@ -25,6 +26,7 @@ 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/memory_studyset_tab_store.dart';
|
||||
import '../../../support/pump_app.dart';
|
||||
import '../../../support/test_dependencies.dart';
|
||||
|
||||
|
|
@ -57,6 +59,7 @@ void main() {
|
|||
String location, {
|
||||
DeviceSettings deviceSettings = const DeviceSettings(),
|
||||
Size size = const Size(1200, 1000),
|
||||
StudysetTabStore? tabs,
|
||||
}) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
|
|
@ -70,6 +73,7 @@ void main() {
|
|||
noteGateway: noteGateway,
|
||||
files: files,
|
||||
deviceSettings: deviceSettings,
|
||||
studysetTabStore: tabs,
|
||||
),
|
||||
location: location,
|
||||
);
|
||||
|
|
@ -116,6 +120,43 @@ void main() {
|
|||
expect(find.text('Particles'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a studyset opens again on the tab it was left on, here, '
|
||||
'even with a path unfinished', (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'),
|
||||
tabs: MemoryStudysetTabStore({'japanese': 'notes'}),
|
||||
);
|
||||
|
||||
expect(shownTab(tester), 2);
|
||||
});
|
||||
|
||||
testWidgets('the tab a studyset shows, chosen on opening or by the '
|
||||
'person, is kept for the next time', (tester) async {
|
||||
final paths = repository();
|
||||
final id = await paths.createPath(
|
||||
'japanese',
|
||||
LearningPathContent.titled('Particles'),
|
||||
);
|
||||
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
|
||||
final tabs = MemoryStudysetTabStore();
|
||||
|
||||
await showApp(tester, RoutePaths.studyset('japanese'), tabs: tabs);
|
||||
expect(shownTab(tester), 0);
|
||||
expect(tabs.kept, {'japanese': 'paths'});
|
||||
|
||||
await tester.tap(find.widgetWithText(Tab, 'Notes'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tabs.kept, {'japanese': 'notes'});
|
||||
});
|
||||
|
||||
testWidgets('a studyset with only notes opens on them', (tester) async {
|
||||
final notes = NoteRepository(noteGateway);
|
||||
addTearDown(notes.dispose);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
|
||||
|
||||
import '../../../support/memory_studyset_tab_store.dart';
|
||||
|
||||
void main() {
|
||||
late Directory folder;
|
||||
|
||||
setUp(() async {
|
||||
folder = await Directory.systemTemp.createTemp('studyset_tabs');
|
||||
});
|
||||
tearDown(() => folder.delete(recursive: true));
|
||||
|
||||
test('keeps the tab each studyset was left on, in a file that a new '
|
||||
'session reads again', () async {
|
||||
final file = File('${folder.path}/studyset_tabs.json');
|
||||
final tabs = StudysetTabs(FileStudysetTabStore(file));
|
||||
await tabs.open();
|
||||
expect(tabs.of('japanese'), isNull);
|
||||
|
||||
await tabs.keep('japanese', 'paths');
|
||||
await tabs.keep('spanish', 'notes');
|
||||
await tabs.keep('japanese', 'flashcards');
|
||||
|
||||
final later = StudysetTabs(FileStudysetTabStore(file));
|
||||
await later.open();
|
||||
expect(later.of('japanese'), 'flashcards');
|
||||
expect(later.of('spanish'), 'notes');
|
||||
});
|
||||
|
||||
test('reads an unreadable file as no tabs kept', () async {
|
||||
final file = File('${folder.path}/studyset_tabs.json')
|
||||
..writeAsStringSync('{not json');
|
||||
final tabs = StudysetTabs(FileStudysetTabStore(file));
|
||||
await tabs.open();
|
||||
|
||||
expect(tabs.of('japanese'), isNull);
|
||||
});
|
||||
|
||||
test('writes only when the tab changes', () async {
|
||||
final kept = {'japanese': 'paths'};
|
||||
final store = MemoryStudysetTabStore(kept);
|
||||
final tabs = StudysetTabs(store);
|
||||
await tabs.open();
|
||||
|
||||
await tabs.keep('japanese', 'paths');
|
||||
expect(store.kept, same(kept));
|
||||
await tabs.keep('japanese', 'notes');
|
||||
expect(store.kept, {'japanese': 'notes'});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
|
||||
|
||||
/// Keeps the studysets' tabs in memory.
|
||||
class MemoryStudysetTabStore([Map<String, String> initial = const {}])
|
||||
implements StudysetTabStore {
|
||||
var _tabs = initial;
|
||||
|
||||
/// What is kept now.
|
||||
Map<String, String> get kept => _tabs;
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> read() async => _tabs;
|
||||
|
||||
@override
|
||||
Future<void> write(Map<String, String> tabs) async => _tabs = tabs;
|
||||
}
|
||||
|
|
@ -69,6 +69,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.d
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
|
||||
|
|
@ -101,6 +102,7 @@ import 'memory_asset_file_cache.dart';
|
|||
import 'memory_device_settings_store.dart';
|
||||
import 'memory_pomodoro_run_store.dart';
|
||||
import 'memory_published_store.dart';
|
||||
import 'memory_studyset_tab_store.dart';
|
||||
|
||||
/// The app's dependencies with fakes instead of the server and the device's
|
||||
/// database; each test passes in only the fakes it looks at.
|
||||
|
|
@ -147,6 +149,7 @@ Dependencies testDependencies({
|
|||
ProfileGateway? profileGateway,
|
||||
PublishGateway? publishGateway,
|
||||
PublishedStore? publishedStore,
|
||||
StudysetTabStore? studysetTabStore,
|
||||
ExploreGateway? exploreGateway,
|
||||
FeedbackWriteGateway? feedbackWriteGateway,
|
||||
FeedbackServerGateway? feedbackServerGateway,
|
||||
|
|
@ -238,6 +241,10 @@ Dependencies testDependencies({
|
|||
final pathDeletions = LearningPathDeletions(paths);
|
||||
final stepDeletions = StepDeletions(paths);
|
||||
final pdfDeletions = PdfDeletions(pdfSources);
|
||||
final studysetTabs = StudysetTabs(
|
||||
studysetTabStore ?? MemoryStudysetTabStore(),
|
||||
);
|
||||
await studysetTabs.open();
|
||||
return PersonDependencies(
|
||||
sync: sync,
|
||||
studysets: studysets,
|
||||
|
|
@ -257,6 +264,7 @@ Dependencies testDependencies({
|
|||
stepDeletions: stepDeletions,
|
||||
pdfDeletions: pdfDeletions,
|
||||
pomodoroRuns: pomodoroRuns ?? MemoryPomodoroRunStore(),
|
||||
studysetTabs: studysetTabs,
|
||||
// The picture as it is: drawing it anew is real work a widget
|
||||
// test's fake clock can't wait for (avatar_image_test.dart has it).
|
||||
profile: ProfileRepository(
|
||||
|
|
|
|||
Loading…
Reference in a new issue