feat(app): the library: a grid of studyset cards, search and archive
The library tab becomes a grid of studyset cards (up to 400 dp wide, all equally tall), starred ones first, and the archive as a section of its own below, closed until opened and counting what rests there. It lists the top level: studysets without a parent, those whose parent is gone, and those in a cycle (ADR 0004). Each card's progress follows the studysets and every change of a card, here or synced, and is worked out again after a study session; a slow, older working out never overwrites a newer one. - Search: a field below the app bar filters by title and secondary title, opens the archive while it finds something there, and says when nothing matches; closing it shows everything again. - Creating and editing: a sheet with the title, the secondary title (typed in capitals, as every surface shows it) and the colour, picked from the palette's twenty, whose names are localised; a new studyset is cobalt, and an existing one keeps its colour, even one only a newer app version knows, until another is picked. Create waits for a title. Closing the sheet with changes asks first. It can't be swiped away: a bottom sheet swiped away closes without asking its PopScope (Flutter issue 152120, still open), so such sheets have no drag handle and close by their close button, the back gesture or a tap beside them, which all ask. - Archiving doesn't ask, since nothing is lost; it offers to undo, as does unarchiving. Deleting asks, then deletes the studyset with its cards, each blanked first. - The create button floats in compact windows and slides away while reading onwards; from medium windows on it sits in the app bar. The old list and its title-only dialog are gone; the studyset screen's tests create studysets through the sheet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
6dc1e863ea
commit
e2e088ce92
19 changed files with 1978 additions and 213 deletions
|
|
@ -14,6 +14,37 @@
|
||||||
"homeOpenLibrary": "Open library",
|
"homeOpenLibrary": "Open library",
|
||||||
|
|
||||||
"libraryTitle": "Library",
|
"libraryTitle": "Library",
|
||||||
|
"libraryNewStudyset": "New studyset",
|
||||||
|
"libraryEmpty": "No studysets yet",
|
||||||
|
"libraryUnreadable": "Couldn't read your studysets.",
|
||||||
|
"libraryArchivedSection": "Archived",
|
||||||
|
"librarySearchTooltip": "Search",
|
||||||
|
"librarySearchHint": "Search studysets",
|
||||||
|
"librarySearchClearTooltip": "Clear query",
|
||||||
|
"librarySearchCloseTooltip": "Close search",
|
||||||
|
"librarySearchNoMatches": "No studysets match your search",
|
||||||
|
"libraryStudysetArchived": "Studyset archived",
|
||||||
|
"libraryStudysetUnarchived": "Studyset unarchived",
|
||||||
|
"libraryDeleteTitle": "Delete studyset?",
|
||||||
|
"libraryDeleteMessage": "It is deleted with its cards, on all your devices.",
|
||||||
|
"libraryDeleteConfirm": "Delete",
|
||||||
|
|
||||||
|
"studysetSheetCreateTitle": "Create studyset",
|
||||||
|
"studysetSheetEditTitle": "Edit studyset",
|
||||||
|
"studysetSheetCreate": "Create",
|
||||||
|
"studysetSheetSave": "Save",
|
||||||
|
"studysetSheetTitleLabel": "Title",
|
||||||
|
"studysetSheetSecondaryLabel": "Secondary title",
|
||||||
|
"studysetSheetSecondaryHelper": "Shown as a small label above the title.",
|
||||||
|
"studysetSheetColor": "Color",
|
||||||
|
"studysetSheetDiscardTitle": "Discard changes?",
|
||||||
|
"studysetSheetDiscardMessage": "You have unsaved changes to this studyset. Discard them and close the form?",
|
||||||
|
"studysetSheetDiscardConfirm": "Discard",
|
||||||
|
|
||||||
|
"paletteColorName": "{name, select, carrot{Carrot} cerise{Cerise} cobalt{Cobalt} copper{Copper} enzian{Enzian} fern{Fern} forest{Forest} ocean{Ocean} olive{Olive} pacific{Pacific} pickle{Pickle} pine{Pine} pink{Pink} plum{Plum} purple{Purple} reef{Reef} sahara{Sahara} slateblue{Slate blue} soil{Soil} strawberry{Strawberry} other{{name}}}",
|
||||||
|
"@paletteColorName": {"description": "A colour of the palette content can take, by its stored name.", "placeholders": {"name": {"type": "String"}}},
|
||||||
|
"colorPickerLabel": "Color: {color}",
|
||||||
|
"@colorPickerLabel": {"placeholders": {"color": {"type": "String"}}},
|
||||||
|
|
||||||
"studysetCountFlashcards": "{count, plural, =1{1 flashcard} other{{count} flashcards}}",
|
"studysetCountFlashcards": "{count, plural, =1{1 flashcard} other{{count} flashcards}}",
|
||||||
"@studysetCountFlashcards": {"placeholders": {"count": {"type": "int"}}},
|
"@studysetCountFlashcards": {"placeholders": {"count": {"type": "int"}}},
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,168 @@ abstract class AppLocalizations {
|
||||||
/// **'Library'**
|
/// **'Library'**
|
||||||
String get libraryTitle;
|
String get libraryTitle;
|
||||||
|
|
||||||
|
/// No description provided for @libraryNewStudyset.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'New studyset'**
|
||||||
|
String get libraryNewStudyset;
|
||||||
|
|
||||||
|
/// No description provided for @libraryEmpty.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No studysets yet'**
|
||||||
|
String get libraryEmpty;
|
||||||
|
|
||||||
|
/// No description provided for @libraryUnreadable.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Couldn\'t read your studysets.'**
|
||||||
|
String get libraryUnreadable;
|
||||||
|
|
||||||
|
/// No description provided for @libraryArchivedSection.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Archived'**
|
||||||
|
String get libraryArchivedSection;
|
||||||
|
|
||||||
|
/// No description provided for @librarySearchTooltip.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Search'**
|
||||||
|
String get librarySearchTooltip;
|
||||||
|
|
||||||
|
/// No description provided for @librarySearchHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Search studysets'**
|
||||||
|
String get librarySearchHint;
|
||||||
|
|
||||||
|
/// No description provided for @librarySearchClearTooltip.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Clear query'**
|
||||||
|
String get librarySearchClearTooltip;
|
||||||
|
|
||||||
|
/// No description provided for @librarySearchCloseTooltip.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Close search'**
|
||||||
|
String get librarySearchCloseTooltip;
|
||||||
|
|
||||||
|
/// No description provided for @librarySearchNoMatches.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No studysets match your search'**
|
||||||
|
String get librarySearchNoMatches;
|
||||||
|
|
||||||
|
/// No description provided for @libraryStudysetArchived.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Studyset archived'**
|
||||||
|
String get libraryStudysetArchived;
|
||||||
|
|
||||||
|
/// No description provided for @libraryStudysetUnarchived.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Studyset unarchived'**
|
||||||
|
String get libraryStudysetUnarchived;
|
||||||
|
|
||||||
|
/// No description provided for @libraryDeleteTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Delete studyset?'**
|
||||||
|
String get libraryDeleteTitle;
|
||||||
|
|
||||||
|
/// No description provided for @libraryDeleteMessage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'It is deleted with its cards, on all your devices.'**
|
||||||
|
String get libraryDeleteMessage;
|
||||||
|
|
||||||
|
/// No description provided for @libraryDeleteConfirm.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Delete'**
|
||||||
|
String get libraryDeleteConfirm;
|
||||||
|
|
||||||
|
/// No description provided for @studysetSheetCreateTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Create studyset'**
|
||||||
|
String get studysetSheetCreateTitle;
|
||||||
|
|
||||||
|
/// No description provided for @studysetSheetEditTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Edit studyset'**
|
||||||
|
String get studysetSheetEditTitle;
|
||||||
|
|
||||||
|
/// No description provided for @studysetSheetCreate.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Create'**
|
||||||
|
String get studysetSheetCreate;
|
||||||
|
|
||||||
|
/// No description provided for @studysetSheetSave.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save'**
|
||||||
|
String get studysetSheetSave;
|
||||||
|
|
||||||
|
/// No description provided for @studysetSheetTitleLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Title'**
|
||||||
|
String get studysetSheetTitleLabel;
|
||||||
|
|
||||||
|
/// No description provided for @studysetSheetSecondaryLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Secondary title'**
|
||||||
|
String get studysetSheetSecondaryLabel;
|
||||||
|
|
||||||
|
/// No description provided for @studysetSheetSecondaryHelper.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Shown as a small label above the title.'**
|
||||||
|
String get studysetSheetSecondaryHelper;
|
||||||
|
|
||||||
|
/// No description provided for @studysetSheetColor.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Color'**
|
||||||
|
String get studysetSheetColor;
|
||||||
|
|
||||||
|
/// No description provided for @studysetSheetDiscardTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Discard changes?'**
|
||||||
|
String get studysetSheetDiscardTitle;
|
||||||
|
|
||||||
|
/// No description provided for @studysetSheetDiscardMessage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'You have unsaved changes to this studyset. Discard them and close the form?'**
|
||||||
|
String get studysetSheetDiscardMessage;
|
||||||
|
|
||||||
|
/// No description provided for @studysetSheetDiscardConfirm.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Discard'**
|
||||||
|
String get studysetSheetDiscardConfirm;
|
||||||
|
|
||||||
|
/// A colour of the palette content can take, by its stored name.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{name, select, carrot{Carrot} cerise{Cerise} cobalt{Cobalt} copper{Copper} enzian{Enzian} fern{Fern} forest{Forest} ocean{Ocean} olive{Olive} pacific{Pacific} pickle{Pickle} pine{Pine} pink{Pink} plum{Plum} purple{Purple} reef{Reef} sahara{Sahara} slateblue{Slate blue} soil{Soil} strawberry{Strawberry} other{{name}}}'**
|
||||||
|
String paletteColorName(String name);
|
||||||
|
|
||||||
|
/// No description provided for @colorPickerLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Color: {color}'**
|
||||||
|
String colorPickerLabel(String color);
|
||||||
|
|
||||||
/// No description provided for @studysetCountFlashcards.
|
/// No description provided for @studysetCountFlashcards.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,120 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get libraryTitle => 'Library';
|
String get libraryTitle => 'Library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryNewStudyset => 'New studyset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryEmpty => 'No studysets yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryUnreadable => 'Couldn\'t read your studysets.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryArchivedSection => 'Archived';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get librarySearchTooltip => 'Search';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get librarySearchHint => 'Search studysets';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get librarySearchClearTooltip => 'Clear query';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get librarySearchCloseTooltip => 'Close search';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get librarySearchNoMatches => 'No studysets match your search';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryStudysetArchived => 'Studyset archived';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryStudysetUnarchived => 'Studyset unarchived';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryDeleteTitle => 'Delete studyset?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryDeleteMessage =>
|
||||||
|
'It is deleted with its cards, on all your devices.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryDeleteConfirm => 'Delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetSheetCreateTitle => 'Create studyset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetSheetEditTitle => 'Edit studyset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetSheetCreate => 'Create';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetSheetSave => 'Save';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetSheetTitleLabel => 'Title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetSheetSecondaryLabel => 'Secondary title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetSheetSecondaryHelper =>
|
||||||
|
'Shown as a small label above the title.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetSheetColor => 'Color';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetSheetDiscardTitle => 'Discard changes?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetSheetDiscardMessage =>
|
||||||
|
'You have unsaved changes to this studyset. Discard them and close the form?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetSheetDiscardConfirm => 'Discard';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String paletteColorName(String name) {
|
||||||
|
String _temp0 = intl.Intl.selectLogic(
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
'carrot': 'Carrot',
|
||||||
|
'cerise': 'Cerise',
|
||||||
|
'cobalt': 'Cobalt',
|
||||||
|
'copper': 'Copper',
|
||||||
|
'enzian': 'Enzian',
|
||||||
|
'fern': 'Fern',
|
||||||
|
'forest': 'Forest',
|
||||||
|
'ocean': 'Ocean',
|
||||||
|
'olive': 'Olive',
|
||||||
|
'pacific': 'Pacific',
|
||||||
|
'pickle': 'Pickle',
|
||||||
|
'pine': 'Pine',
|
||||||
|
'pink': 'Pink',
|
||||||
|
'plum': 'Plum',
|
||||||
|
'purple': 'Purple',
|
||||||
|
'reef': 'Reef',
|
||||||
|
'sahara': 'Sahara',
|
||||||
|
'slateblue': 'Slate blue',
|
||||||
|
'soil': 'Soil',
|
||||||
|
'strawberry': 'Strawberry',
|
||||||
|
'other': '$name',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String colorPickerLabel(String color) {
|
||||||
|
return 'Color: $color';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String studysetCountFlashcards(int count) {
|
String studysetCountFlashcards(int count) {
|
||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_session_bloc.
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_screen.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_screen.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_edit_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_edit_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/widget/studyset_list_screen.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/library_screen.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
|
||||||
// Material's Card widget would clash with the domain's Card.
|
// Material's Card widget would clash with the domain's Card.
|
||||||
import 'package:material_ui/material_ui.dart' hide Card;
|
import 'package:material_ui/material_ui.dart' hide Card;
|
||||||
|
|
@ -44,7 +44,7 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RoutePaths.library,
|
path: RoutePaths.library,
|
||||||
builder: (context, state) => const StudysetListScreen(),
|
builder: (context, state) => const LibraryScreen(),
|
||||||
// Nested, so a studyset's screen sits above the library in the
|
// Nested, so a studyset's screen sits above the library in the
|
||||||
// tab's stack.
|
// tab's stack.
|
||||||
routes: [
|
routes: [
|
||||||
|
|
|
||||||
|
|
@ -13,17 +13,29 @@ import 'package:material_ui/material_ui.dart';
|
||||||
/// surface: medium windows are mostly tablets, expanded ones mostly
|
/// surface: medium windows are mostly tablets, expanded ones mostly
|
||||||
/// desktops, where a sheet rising from the bottom of a monitor reads as a
|
/// desktops, where a sheet rising from the bottom of a monitor reads as a
|
||||||
/// phone screen moved over.
|
/// phone screen moved over.
|
||||||
|
///
|
||||||
|
/// A sheet that asks before discarding changes (with a `PopScope`) passes
|
||||||
|
/// [swipeable] false: a bottom sheet swiped away closes without asking its
|
||||||
|
/// `PopScope` (Flutter issue 152120, open with v3.47), so it then has no
|
||||||
|
/// drag handle, and closes by its close button, the back gesture or a tap
|
||||||
|
/// beside it, which all ask.
|
||||||
Future<T?> showAdaptiveSheet<T>(
|
Future<T?> showAdaptiveSheet<T>(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required WidgetBuilder builder,
|
required WidgetBuilder builder,
|
||||||
|
bool swipeable = true,
|
||||||
}) {
|
}) {
|
||||||
if (!context.windowSize.isAtLeastExpanded) {
|
if (!context.windowSize.isAtLeastExpanded) {
|
||||||
return showModalBottomSheet<T>(
|
return showModalBottomSheet<T>(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
showDragHandle: true,
|
showDragHandle: swipeable,
|
||||||
builder: builder,
|
enableDrag: swipeable,
|
||||||
|
builder: (context) => _Presentation(
|
||||||
|
isDialog: false,
|
||||||
|
hasDragHandle: swipeable,
|
||||||
|
child: builder(context),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return showDialog<T>(
|
return showDialog<T>(
|
||||||
|
|
@ -32,23 +44,37 @@ Future<T?> showAdaptiveSheet<T>(
|
||||||
// More room than an alert's default: sheets hold forms and lists.
|
// More room than an alert's default: sheets hold forms and lists.
|
||||||
insetPadding: const EdgeInsets.symmetric(horizontal: 40, vertical: 32),
|
insetPadding: const EdgeInsets.symmetric(horizontal: 40, vertical: 32),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: _Presentation(isDialog: true, child: builder(context)),
|
child: _Presentation(
|
||||||
|
isDialog: true,
|
||||||
|
hasDragHandle: false,
|
||||||
|
child: builder(context),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tells a [SheetScaffold] whether it shows as a dialog.
|
/// Tells a [SheetScaffold] how it shows: as a dialog or a bottom sheet, and
|
||||||
|
/// whether a drag handle sits above it.
|
||||||
class const _Presentation({
|
class const _Presentation({
|
||||||
required final bool isDialog,
|
required final bool isDialog,
|
||||||
|
required final bool hasDragHandle,
|
||||||
required super.child,
|
required super.child,
|
||||||
}) extends InheritedWidget {
|
}) extends InheritedWidget {
|
||||||
static bool isDialogAt(BuildContext context) =>
|
/// How the sheet at [context] shows; a sheet shown some other way is a
|
||||||
context.dependOnInheritedWidgetOfExactType<_Presentation>()?.isDialog ??
|
/// bottom sheet with a handle, as Material's own are.
|
||||||
false;
|
static ({bool isDialog, bool hasDragHandle}) at(BuildContext context) =>
|
||||||
|
switch (context.dependOnInheritedWidgetOfExactType<_Presentation>()) {
|
||||||
|
final presentation? => (
|
||||||
|
isDialog: presentation.isDialog,
|
||||||
|
hasDragHandle: presentation.hasDragHandle,
|
||||||
|
),
|
||||||
|
null => (isDialog: false, hasDragHandle: true),
|
||||||
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool updateShouldNotify(_Presentation oldWidget) =>
|
bool updateShouldNotify(_Presentation oldWidget) =>
|
||||||
isDialog != oldWidget.isDialog;
|
isDialog != oldWidget.isDialog ||
|
||||||
|
hasDragHandle != oldWidget.hasDragHandle;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The frame of a sheet from [showAdaptiveSheet]: the [title] with a close
|
/// The frame of a sheet from [showAdaptiveSheet]: the [title] with a close
|
||||||
|
|
@ -91,7 +117,8 @@ class const SheetScaffold({
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final media = MediaQuery.of(context);
|
final media = MediaQuery.of(context);
|
||||||
final isDialog = _Presentation.isDialogAt(context);
|
final presentation = _Presentation.at(context);
|
||||||
|
final isDialog = presentation.isDialog;
|
||||||
final heightFactor = this.heightFactor;
|
final heightFactor = this.heightFactor;
|
||||||
final column = Column(
|
final column = Column(
|
||||||
mainAxisSize: isDialog || heightFactor == null
|
mainAxisSize: isDialog || heightFactor == null
|
||||||
|
|
@ -123,8 +150,13 @@ class const SheetScaffold({
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// The drag handle above gives the top its space.
|
// A drag handle above gives the top its space.
|
||||||
const padding = EdgeInsets.fromLTRB(20, 0, 20, 16);
|
final padding = EdgeInsets.fromLTRB(
|
||||||
|
20,
|
||||||
|
presentation.hasDragHandle ? 0 : 16,
|
||||||
|
20,
|
||||||
|
16,
|
||||||
|
);
|
||||||
// What's left once the keyboard and the system's bottom inset are
|
// What's left once the keyboard and the system's bottom inset are
|
||||||
// taken: raising the keyboard shrinks the body rather than pushing the
|
// taken: raising the keyboard shrinks the body rather than pushing the
|
||||||
// sheet off the window.
|
// sheet off the window.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/theme/palette_color.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// A field holding one of the palette's colours: it shows the colour with its
|
||||||
|
/// name, and opens a grid of all twenty to choose from.
|
||||||
|
class const PaletteColorPicker({
|
||||||
|
/// The colour chosen.
|
||||||
|
required final PaletteColor selected,
|
||||||
|
|
||||||
|
/// Called with the colour the person chooses.
|
||||||
|
required final ValueChanged<PaletteColor> onChanged,
|
||||||
|
super.key,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<PaletteColorPicker> createState() => _PaletteColorPickerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PaletteColorPickerState() extends State<PaletteColorPicker> {
|
||||||
|
final _menu = MenuController();
|
||||||
|
var _open = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final name = l10n.paletteColorName(widget.selected.name);
|
||||||
|
return MenuAnchor(
|
||||||
|
controller: _menu,
|
||||||
|
onOpen: () => setState(() => _open = true),
|
||||||
|
onClose: () => setState(() => _open = false),
|
||||||
|
style: MenuStyle(
|
||||||
|
padding: const WidgetStatePropertyAll(EdgeInsets.all(12)),
|
||||||
|
backgroundColor: WidgetStatePropertyAll(
|
||||||
|
theme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
shape: WidgetStatePropertyAll(
|
||||||
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
menuChildren: [
|
||||||
|
_Grid(
|
||||||
|
selected: widget.selected,
|
||||||
|
onChanged: (color) {
|
||||||
|
widget.onChanged(color);
|
||||||
|
_menu.close();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
builder: (context, menu, _) => Semantics(
|
||||||
|
button: true,
|
||||||
|
label: l10n.colorPickerLabel(name),
|
||||||
|
excludeSemantics: true,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => menu.isOpen ? menu.close() : menu.open(),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
// Like an outlined text field: its outline thickens and takes
|
||||||
|
// the primary colour while open.
|
||||||
|
border: Border.all(
|
||||||
|
color: _open
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.outline,
|
||||||
|
width: _open ? 2 : 1,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.all(_open ? 11 : 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_Dot(color: widget.selected.background, size: 16),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Text(name, style: theme.textTheme.bodyMedium)),
|
||||||
|
Icon(
|
||||||
|
_open ? LucideIcons.chevronUp : LucideIcons.chevronDown,
|
||||||
|
size: 18,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All twenty colours, the chosen one ringed in the primary colour.
|
||||||
|
class const _Grid({
|
||||||
|
required final PaletteColor selected,
|
||||||
|
required final ValueChanged<PaletteColor> onChanged,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
return ConstrainedBox(
|
||||||
|
// Five colours a row.
|
||||||
|
constraints: const BoxConstraints(maxWidth: 232),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (final color in PaletteColor.values)
|
||||||
|
Semantics(
|
||||||
|
label: l10n.paletteColorName(color.name),
|
||||||
|
selected: color == selected,
|
||||||
|
button: true,
|
||||||
|
excludeSemantics: true,
|
||||||
|
child: InkResponse(
|
||||||
|
onTap: () => onChanged(color),
|
||||||
|
radius: 24,
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.background,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: color == selected
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: color.outline,
|
||||||
|
width: color == selected ? 3 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class const _Dot({required final Color color, required final double size})
|
||||||
|
extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,10 @@ class CardRepository(final CardGateway _gateway) {
|
||||||
final _changes = StreamController<void>.broadcast();
|
final _changes = StreamController<void>.broadcast();
|
||||||
late final StreamSubscription<void> _remoteChanges;
|
late final StreamSubscription<void> _remoteChanges;
|
||||||
|
|
||||||
|
/// Fires after every change of any card, made on this device or synced
|
||||||
|
/// from another.
|
||||||
|
Stream<void> get changes => _changes.stream;
|
||||||
|
|
||||||
/// The cards of [studysetId] in order, now and after every change, local or
|
/// The cards of [studysetId] in order, now and after every change, local or
|
||||||
/// synced. A read that fails arrives as an error; the next change reads
|
/// synced. A read that fails arrives as an error; the next change reads
|
||||||
/// again.
|
/// again.
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,88 @@
|
||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:clock/clock.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.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_content.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||||
|
|
||||||
/// What happens to the list of studysets.
|
/// What happens in the library.
|
||||||
sealed class const StudysetListEvent() with Equatable {
|
sealed class const StudysetListEvent() with Equatable {
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => const [];
|
List<Object?> get props => const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The list opens: follow the person's studysets.
|
/// The library opens: follow the person's studysets and their cards.
|
||||||
final class const StudysetListEventStarted() extends StudysetListEvent;
|
final class const StudysetListEventStarted() extends StudysetListEvent;
|
||||||
|
|
||||||
/// Create a studyset with [title].
|
/// Create a studyset with [content].
|
||||||
final class const StudysetListEventCreateRequested(
|
final class const StudysetListEventCreateRequested(
|
||||||
/// The new studyset's title.
|
/// The new studyset's content.
|
||||||
final String title,
|
final StudysetContent content,
|
||||||
) extends StudysetListEvent {
|
) extends StudysetListEvent {
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [title];
|
List<Object?> get props => [content];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The person's studysets, as the list shows them.
|
/// Replace the content of the studyset [id] with [content].
|
||||||
|
final class const StudysetListEventContentChanged(
|
||||||
|
/// The studyset's id.
|
||||||
|
final String id,
|
||||||
|
|
||||||
|
/// Its new content.
|
||||||
|
final StudysetContent content,
|
||||||
|
) extends StudysetListEvent {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id, content];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Star [studyset], or take its star away.
|
||||||
|
final class const StudysetListEventStarToggled(
|
||||||
|
/// The studyset as the library shows it.
|
||||||
|
final Studyset studyset,
|
||||||
|
) extends StudysetListEvent {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [studyset];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Archive the studyset [id].
|
||||||
|
final class const StudysetListEventArchiveRequested(
|
||||||
|
/// The studyset's id.
|
||||||
|
final String id,
|
||||||
|
) extends StudysetListEvent {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take the studyset [id] out of the archive.
|
||||||
|
final class const StudysetListEventUnarchiveRequested(
|
||||||
|
/// The studyset's id.
|
||||||
|
final String id,
|
||||||
|
) extends StudysetListEvent {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the studyset [id] with its cards.
|
||||||
|
final class const StudysetListEventDeleteRequested(
|
||||||
|
/// The studyset's id.
|
||||||
|
final String id,
|
||||||
|
) extends StudysetListEvent {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Work out the studysets' progress again, as after a study session.
|
||||||
|
final class const StudysetListEventProgressRefreshRequested()
|
||||||
|
extends StudysetListEvent;
|
||||||
|
|
||||||
|
/// The person's studysets, as the library shows them.
|
||||||
sealed class const StudysetListState() with Equatable;
|
sealed class const StudysetListState() with Equatable;
|
||||||
|
|
||||||
/// Reading the studysets.
|
/// Reading the studysets.
|
||||||
|
|
@ -37,37 +97,130 @@ final class const StudysetListStateFailed() extends StudysetListState {
|
||||||
List<Object?> get props => const [];
|
List<Object?> get props => const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The studysets, oldest first.
|
/// The top-level studysets, starred ones first, each group oldest first,
|
||||||
|
/// with their progress.
|
||||||
final class const StudysetListStateLoaded(
|
final class const StudysetListStateLoaded(
|
||||||
/// The studysets on this device.
|
/// The studysets at the top level.
|
||||||
final List<Studyset> studysets,
|
final List<Studyset> studysets, {
|
||||||
) extends StudysetListState {
|
|
||||||
|
/// Each studyset's progress, by its id; a studyset without one is still
|
||||||
|
/// being worked out.
|
||||||
|
final Map<String, StudyProgress> progress = const {},
|
||||||
|
}) extends StudysetListState {
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [studysets];
|
List<Object?> get props => [studysets, progress];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows the person's studysets and creates new ones; the list follows every
|
/// The library: the person's studysets with the progress of their cards,
|
||||||
/// change, local or synced.
|
/// following every change, local or synced; creates, edits, stars, archives
|
||||||
class StudysetListBloc(final StudysetRepository _studysets)
|
/// and deletes studysets.
|
||||||
extends Bloc<StudysetListEvent, StudysetListState> {
|
class StudysetListBloc(
|
||||||
|
final StudysetRepository _studysets,
|
||||||
|
final CardRepository _cards,
|
||||||
|
final ReviewRepository _reviews,
|
||||||
|
) extends Bloc<StudysetListEvent, StudysetListState> {
|
||||||
this : super(const StudysetListStateLoading()) {
|
this : super(const StudysetListStateLoading()) {
|
||||||
on<StudysetListEventStarted>(_onStarted);
|
on<StudysetListEventStarted>(_onStarted);
|
||||||
on<StudysetListEventCreateRequested>(_onCreateRequested);
|
on<StudysetListEventCreateRequested>(
|
||||||
|
(event, emit) => _studysets.create(event.content),
|
||||||
|
);
|
||||||
|
on<StudysetListEventContentChanged>(
|
||||||
|
(event, emit) => _studysets.updateContent(event.id, event.content),
|
||||||
|
);
|
||||||
|
on<StudysetListEventStarToggled>(
|
||||||
|
(event, emit) => event.studyset.isStarred
|
||||||
|
? _studysets.unstar(event.studyset.id)
|
||||||
|
: _studysets.star(event.studyset.id),
|
||||||
|
);
|
||||||
|
on<StudysetListEventArchiveRequested>(
|
||||||
|
(event, emit) => _studysets.archive(event.id),
|
||||||
|
);
|
||||||
|
on<StudysetListEventUnarchiveRequested>(
|
||||||
|
(event, emit) => _studysets.unarchive(event.id),
|
||||||
|
);
|
||||||
|
on<StudysetListEventDeleteRequested>(_onDeleteRequested);
|
||||||
|
on<StudysetListEventProgressRefreshRequested>(_onProgressRefreshRequested);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How often the progress was asked for, so that a slow, older working
|
||||||
|
/// out never overwrites a newer one.
|
||||||
|
var _progressRequests = 0;
|
||||||
|
|
||||||
Future<void> _onStarted(
|
Future<void> _onStarted(
|
||||||
StudysetListEventStarted event,
|
StudysetListEventStarted event,
|
||||||
Emitter<StudysetListState> emit,
|
Emitter<StudysetListState> emit,
|
||||||
) => emit.forEach(
|
) => Future.wait([
|
||||||
_studysets.watchAll(),
|
emit.forEach(
|
||||||
onData: StudysetListStateLoaded.new,
|
_studysets.watchAll(),
|
||||||
onError: (_, _) => const StudysetListStateFailed(),
|
onData: (studysets) {
|
||||||
);
|
add(const StudysetListEventProgressRefreshRequested());
|
||||||
|
return StudysetListStateLoaded(
|
||||||
|
_inLibraryOrder(studysets),
|
||||||
|
// Until the new progress is worked out, the last one stands.
|
||||||
|
progress: switch (state) {
|
||||||
|
StudysetListStateLoaded(:final progress) => progress,
|
||||||
|
_ => const {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (_, _) => const StudysetListStateFailed(),
|
||||||
|
),
|
||||||
|
// Changed cards, here or on another device, change the progress too.
|
||||||
|
emit.onEach(
|
||||||
|
_cards.changes,
|
||||||
|
onData: (_) => add(const StudysetListEventProgressRefreshRequested()),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
Future<void> _onCreateRequested(
|
Future<void> _onDeleteRequested(
|
||||||
StudysetListEventCreateRequested event,
|
StudysetListEventDeleteRequested event,
|
||||||
Emitter<StudysetListState> emit,
|
Emitter<StudysetListState> emit,
|
||||||
) async {
|
) async {
|
||||||
await _studysets.create(StudysetContent.titled(event.title));
|
// Its cards first, each blanked, so no card content outlives the
|
||||||
|
// studyset (ADR 0002, rule 2).
|
||||||
|
await _cards.deleteAllOf(event.id);
|
||||||
|
await _studysets.delete(event.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onProgressRefreshRequested(
|
||||||
|
StudysetListEventProgressRefreshRequested event,
|
||||||
|
Emitter<StudysetListState> emit,
|
||||||
|
) async {
|
||||||
|
final request = ++_progressRequests;
|
||||||
|
if (state case StudysetListStateLoaded(:final studysets)) {
|
||||||
|
final now = clock.now().toUtc();
|
||||||
|
final progress = <String, StudyProgress>{};
|
||||||
|
try {
|
||||||
|
for (final studyset in studysets) {
|
||||||
|
final cardIds = [
|
||||||
|
for (final card in await _cards.cardsOf(studyset.id)) card.id,
|
||||||
|
];
|
||||||
|
progress[studyset.id] = studyProgress(
|
||||||
|
cardOrder: cardIds,
|
||||||
|
schedules: await _reviews.schedulesOf(cardIds),
|
||||||
|
now: now,
|
||||||
|
startOfToday: startOfLocalDay(now),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} on Object catch (error) {
|
||||||
|
// The last progress stands until the next change works it out.
|
||||||
|
log('Working out the progress failed', name: 'Library', error: error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state case StudysetListStateLoaded(:final studysets)
|
||||||
|
when request == _progressRequests) {
|
||||||
|
emit(StudysetListStateLoaded(studysets, progress: progress));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The top-level studysets, starred ones first; both groups keep their order
|
||||||
|
/// (oldest first).
|
||||||
|
List<Studyset> _inLibraryOrder(List<Studyset> all) {
|
||||||
|
final top = topLevel(all);
|
||||||
|
return [
|
||||||
|
...top.where((studyset) => studyset.isStarred),
|
||||||
|
...top.where((studyset) => !studyset.isStarred),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||||
|
|
||||||
|
/// The studysets of [all] that show at the top level, in their order: those
|
||||||
|
/// without a parent, those whose parent is gone (deleted, or not synced
|
||||||
|
/// here yet), and those in a cycle, which two devices moving studysets into
|
||||||
|
/// each other at once can make (ADR 0004). Everything else sits inside one
|
||||||
|
/// of them.
|
||||||
|
List<Studyset> topLevel(List<Studyset> all) {
|
||||||
|
final byId = {for (final studyset in all) studyset.id: studyset};
|
||||||
|
return [
|
||||||
|
for (final studyset in all)
|
||||||
|
if (_isTopLevel(studyset, byId)) studyset,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isTopLevel(Studyset studyset, Map<String, Studyset> byId) {
|
||||||
|
final parentId = studyset.parentId;
|
||||||
|
if (parentId == null || !byId.containsKey(parentId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// In a cycle, following the parents leads back to the studyset itself. A
|
||||||
|
// chain that ends, or loops without it, has it inside something else.
|
||||||
|
final seen = <String>{};
|
||||||
|
String? current = parentId;
|
||||||
|
while (current != null && seen.add(current)) {
|
||||||
|
if (current == studyset.id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
current = byId[current]?.parentId;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,426 @@
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||||
|
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/widget/app_loading_indicator.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.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';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.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/widget/studyset_card.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_sheet.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// The library tab: the person's studysets as cards in a grid, starred ones
|
||||||
|
/// first, and archived ones in a section of their own below, closed until
|
||||||
|
/// opened. A search above the grid finds studysets by title and secondary
|
||||||
|
/// title, archived ones too.
|
||||||
|
///
|
||||||
|
/// Creating a studyset is a floating button in compact windows, which slides
|
||||||
|
/// away while reading onwards, and a button in the app bar from medium
|
||||||
|
/// windows on.
|
||||||
|
class const LibraryScreen({super.key}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final person = PersonScope.of(context);
|
||||||
|
return BlocProvider(
|
||||||
|
create: (context) =>
|
||||||
|
StudysetListBloc(person.studysets, person.cards, person.reviews)
|
||||||
|
..add(const StudysetListEventStarted()),
|
||||||
|
child: const _Library(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class const _Library() extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_Library> createState() => _LibraryState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LibraryState() extends State<_Library> {
|
||||||
|
var _searching = false;
|
||||||
|
final _query = TextEditingController();
|
||||||
|
final _queryFocus = FocusNode();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_query.dispose();
|
||||||
|
_queryFocus.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the search, ready to type, or closes it, which shows everything
|
||||||
|
/// again.
|
||||||
|
void _toggleSearch() {
|
||||||
|
setState(() {
|
||||||
|
_searching = !_searching;
|
||||||
|
if (!_searching) {
|
||||||
|
_query.clear();
|
||||||
|
_queryFocus.unfocus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (_searching) {
|
||||||
|
// The field only takes focus once it is there.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
_queryFocus.requestFocus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _create(BuildContext context) async {
|
||||||
|
final bloc = context.read<StudysetListBloc>();
|
||||||
|
final content = await showStudysetSheet(context);
|
||||||
|
if (content != null) {
|
||||||
|
bloc.add(StudysetListEventCreateRequested(content));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final wide = context.windowSize.isAtLeastMedium;
|
||||||
|
return ScrollHidingFab(
|
||||||
|
fab: wide
|
||||||
|
? null
|
||||||
|
: FloatingActionButton(
|
||||||
|
tooltip: l10n.libraryNewStudyset,
|
||||||
|
onPressed: () => _create(context),
|
||||||
|
child: const Icon(LucideIcons.plus),
|
||||||
|
),
|
||||||
|
builder: (context, fab) => Scaffold(
|
||||||
|
// The cards stand out from it by their shadow.
|
||||||
|
backgroundColor: theme.colorScheme.surfaceContainerLow,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(l10n.libraryTitle),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: l10n.librarySearchTooltip,
|
||||||
|
// Tinted while the search narrows what the grid shows.
|
||||||
|
icon: Icon(
|
||||||
|
LucideIcons.search,
|
||||||
|
color: _searching ? theme.colorScheme.primary : null,
|
||||||
|
),
|
||||||
|
onPressed: _toggleSearch,
|
||||||
|
),
|
||||||
|
if (wide)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: FilledButton.tonalIcon(
|
||||||
|
onPressed: () => _create(context),
|
||||||
|
icon: const Icon(LucideIcons.plus),
|
||||||
|
label: Text(l10n.libraryNewStudyset),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: fab,
|
||||||
|
body: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (_searching)
|
||||||
|
_SearchRow(
|
||||||
|
controller: _query,
|
||||||
|
focusNode: _queryFocus,
|
||||||
|
onChanged: () => setState(() {}),
|
||||||
|
onClose: _toggleSearch,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: BlocBuilder<StudysetListBloc, StudysetListState>(
|
||||||
|
builder: (context, state) => switch (state) {
|
||||||
|
StudysetListStateLoading() => const Center(
|
||||||
|
child: AppLoadingIndicator(),
|
||||||
|
),
|
||||||
|
StudysetListStateFailed() => _Message(l10n.libraryUnreadable),
|
||||||
|
StudysetListStateLoaded(studysets: []) => _Message(
|
||||||
|
l10n.libraryEmpty,
|
||||||
|
),
|
||||||
|
StudysetListStateLoaded(:final studysets, :final progress) =>
|
||||||
|
_Grid(
|
||||||
|
studysets: studysets,
|
||||||
|
progress: progress,
|
||||||
|
query: _query.text,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A line in the middle of the library, such as "No studysets yet".
|
||||||
|
class const _Message(final String text) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cards: active studysets, then the archive's section.
|
||||||
|
class const _Grid({
|
||||||
|
required final List<Studyset> studysets,
|
||||||
|
required final Map<String, StudyProgress> progress,
|
||||||
|
required final String query,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_Grid> createState() => _GridState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GridState() extends State<_Grid> {
|
||||||
|
var _archiveOpen = false;
|
||||||
|
|
||||||
|
static const _spacing = 12.0;
|
||||||
|
|
||||||
|
// As many columns as fit cards up to 400 dp wide; every card as tall, so
|
||||||
|
// a row lines up.
|
||||||
|
static const _layout = SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
maxCrossAxisExtent: 400,
|
||||||
|
mainAxisExtent: 188,
|
||||||
|
crossAxisSpacing: _spacing,
|
||||||
|
mainAxisSpacing: _spacing,
|
||||||
|
);
|
||||||
|
|
||||||
|
static bool _matches(Studyset studyset, String query) =>
|
||||||
|
studyset.content.title.toLowerCase().contains(query) ||
|
||||||
|
(studyset.content.secondaryTitle?.toLowerCase().contains(query) ?? false);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final query = widget.query.trim().toLowerCase();
|
||||||
|
final searching = query.isNotEmpty;
|
||||||
|
final active = [
|
||||||
|
for (final studyset in widget.studysets)
|
||||||
|
if (!studyset.isArchived && (!searching || _matches(studyset, query)))
|
||||||
|
studyset,
|
||||||
|
];
|
||||||
|
final archived = [
|
||||||
|
for (final studyset in widget.studysets)
|
||||||
|
if (studyset.isArchived && (!searching || _matches(studyset, query)))
|
||||||
|
studyset,
|
||||||
|
];
|
||||||
|
// A search opens the archive, so its matches show; closing the search
|
||||||
|
// brings back the section as the person left it.
|
||||||
|
final archiveOpen = searching || _archiveOpen;
|
||||||
|
return CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
if (searching && active.isEmpty && archived.isEmpty)
|
||||||
|
SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: _Message(l10n.librarySearchNoMatches),
|
||||||
|
),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.all(_spacing),
|
||||||
|
sliver: SliverGrid.builder(
|
||||||
|
gridDelegate: _layout,
|
||||||
|
itemCount: active.length,
|
||||||
|
itemBuilder: (context, index) => _card(context, active[index]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (archived.isNotEmpty) ...[
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: _ArchiveHeader(
|
||||||
|
count: archived.length,
|
||||||
|
open: archiveOpen,
|
||||||
|
onToggle: () => setState(() => _archiveOpen = !_archiveOpen),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (archiveOpen)
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
_spacing,
|
||||||
|
0,
|
||||||
|
_spacing,
|
||||||
|
_spacing,
|
||||||
|
),
|
||||||
|
sliver: SliverGrid.builder(
|
||||||
|
gridDelegate: _layout,
|
||||||
|
itemCount: archived.length,
|
||||||
|
itemBuilder: (context, index) =>
|
||||||
|
_card(context, archived[index]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _card(BuildContext context, Studyset studyset) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final bloc = context.read<StudysetListBloc>();
|
||||||
|
return StudysetCard(
|
||||||
|
key: ValueKey(studyset.id),
|
||||||
|
studyset: studyset,
|
||||||
|
progress: widget.progress[studyset.id],
|
||||||
|
// Within the library tab, so its back button returns here.
|
||||||
|
onOpen: () => context.go(RoutePaths.studyset(studyset.id)),
|
||||||
|
onStudy: () async {
|
||||||
|
await context.push(RoutePaths.study(studyset.id));
|
||||||
|
// The session's reviews changed what is due.
|
||||||
|
bloc.add(const StudysetListEventProgressRefreshRequested());
|
||||||
|
},
|
||||||
|
onToggleStar: () => bloc.add(StudysetListEventStarToggled(studyset)),
|
||||||
|
onEdit: () async {
|
||||||
|
final content = await showStudysetSheet(
|
||||||
|
context,
|
||||||
|
initial: studyset.content,
|
||||||
|
);
|
||||||
|
if (content != null && content != studyset.content) {
|
||||||
|
bloc.add(StudysetListEventContentChanged(studyset.id, content));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Archiving is tidying up, not destroying, so it doesn't ask first:
|
||||||
|
// it offers to undo.
|
||||||
|
onArchive: () {
|
||||||
|
bloc.add(StudysetListEventArchiveRequested(studyset.id));
|
||||||
|
showUndoSnackBar(
|
||||||
|
context,
|
||||||
|
message: l10n.libraryStudysetArchived,
|
||||||
|
onUndo: () =>
|
||||||
|
bloc.add(StudysetListEventUnarchiveRequested(studyset.id)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onUnarchive: () {
|
||||||
|
bloc.add(StudysetListEventUnarchiveRequested(studyset.id));
|
||||||
|
showUndoSnackBar(
|
||||||
|
context,
|
||||||
|
message: l10n.libraryStudysetUnarchived,
|
||||||
|
onUndo: () =>
|
||||||
|
bloc.add(StudysetListEventArchiveRequested(studyset.id)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onDelete: () async {
|
||||||
|
if (await confirmDestructiveAction(
|
||||||
|
context,
|
||||||
|
title: l10n.libraryDeleteTitle,
|
||||||
|
message: l10n.libraryDeleteMessage,
|
||||||
|
confirmLabel: l10n.libraryDeleteConfirm,
|
||||||
|
)) {
|
||||||
|
bloc.add(StudysetListEventDeleteRequested(studyset.id));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The archive's header: it opens and closes the section, and counts what
|
||||||
|
/// rests there.
|
||||||
|
class const _ArchiveHeader({
|
||||||
|
required final int count,
|
||||||
|
required final bool open,
|
||||||
|
required final VoidCallback onToggle,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return InkWell(
|
||||||
|
onTap: onToggle,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
open ? LucideIcons.chevronDown : LucideIcons.chevronRight,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
AppLocalizations.of(context).libraryArchivedSection,
|
||||||
|
style: theme.textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text('$count', style: theme.textTheme.labelSmall),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The search field below the app bar: its own × clears what is typed, the
|
||||||
|
/// one beside it closes the search.
|
||||||
|
class const _SearchRow({
|
||||||
|
required final TextEditingController controller,
|
||||||
|
required final FocusNode focusNode,
|
||||||
|
required final VoidCallback onChanged,
|
||||||
|
required final VoidCallback onClose,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 8, 0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 44,
|
||||||
|
child: TextField(
|
||||||
|
controller: controller,
|
||||||
|
focusNode: focusNode,
|
||||||
|
onChanged: (_) => onChanged(),
|
||||||
|
textInputAction: TextInputAction.search,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
hintText: l10n.librarySearchHint,
|
||||||
|
prefixIcon: const Icon(LucideIcons.search, size: 18),
|
||||||
|
prefixIconConstraints: const BoxConstraints(
|
||||||
|
minWidth: 40,
|
||||||
|
minHeight: 40,
|
||||||
|
),
|
||||||
|
suffixIcon: controller.text.isEmpty
|
||||||
|
? null
|
||||||
|
: IconButton(
|
||||||
|
tooltip: l10n.librarySearchClearTooltip,
|
||||||
|
iconSize: 18,
|
||||||
|
icon: const Icon(LucideIcons.x),
|
||||||
|
onPressed: () {
|
||||||
|
controller.clear();
|
||||||
|
onChanged();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: l10n.librarySearchCloseTooltip,
|
||||||
|
icon: const Icon(LucideIcons.x),
|
||||||
|
onPressed: onClose,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
|
||||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
|
||||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
|
||||||
import 'package:material_ui/material_ui.dart';
|
|
||||||
|
|
||||||
/// The library tab: the person's studysets.
|
|
||||||
class const StudysetListScreen({super.key}) extends StatelessWidget {
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => BlocProvider(
|
|
||||||
create: (context) =>
|
|
||||||
StudysetListBloc(PersonScope.of(context).studysets)
|
|
||||||
..add(const StudysetListEventStarted()),
|
|
||||||
child: const _StudysetListView(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class const _StudysetListView() extends StatelessWidget {
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => Scaffold(
|
|
||||||
appBar: AppBar(title: Text(AppLocalizations.of(context).libraryTitle)),
|
|
||||||
body: BlocBuilder<StudysetListBloc, StudysetListState>(
|
|
||||||
builder: (context, state) => switch (state) {
|
|
||||||
StudysetListStateLoading() => const Center(
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
StudysetListStateFailed() => const Center(
|
|
||||||
child: Text("Couldn't read your studysets."),
|
|
||||||
),
|
|
||||||
StudysetListStateLoaded(studysets: []) => const Center(
|
|
||||||
child: Text('No studysets yet.'),
|
|
||||||
),
|
|
||||||
StudysetListStateLoaded(:final studysets) => ListView(
|
|
||||||
children: [
|
|
||||||
for (final studyset in studysets)
|
|
||||||
ListTile(
|
|
||||||
title: Text(studyset.content.title),
|
|
||||||
onTap: () => context.go(RoutePaths.studyset(studyset.id)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text('New studyset'),
|
|
||||||
onPressed: () async {
|
|
||||||
final bloc = context.read<StudysetListBloc>();
|
|
||||||
final title = await _askForTitle(context);
|
|
||||||
if (title != null) {
|
|
||||||
bloc.add(StudysetListEventCreateRequested(title));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Asks for a new studyset's title; null when cancelled or left empty.
|
|
||||||
Future<String?> _askForTitle(BuildContext context) async {
|
|
||||||
final title = await showDialog<String>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => const _NewStudysetDialog(),
|
|
||||||
);
|
|
||||||
final trimmed = title?.trim();
|
|
||||||
return (trimmed == null || trimmed.isEmpty) ? null : trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
class const _NewStudysetDialog() extends StatefulWidget {
|
|
||||||
@override
|
|
||||||
State<_NewStudysetDialog> createState() => _NewStudysetDialogState();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Owns the field's controller: `showDialog` returns before the dialog's closing
|
|
||||||
// animation ends, and the field still uses the controller until then.
|
|
||||||
class _NewStudysetDialogState() extends State<_NewStudysetDialog> {
|
|
||||||
final _title = TextEditingController();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_title.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) => AlertDialog(
|
|
||||||
title: const Text('New studyset'),
|
|
||||||
content: TextField(
|
|
||||||
controller: _title,
|
|
||||||
autofocus: true,
|
|
||||||
decoration: const InputDecoration(labelText: 'Title'),
|
|
||||||
onSubmitted: (title) => Navigator.of(context).pop(title),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(_title.text),
|
|
||||||
child: const Text('Create'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.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/confirm_dialog.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/widget/palette_color_picker.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// Creates a studyset's content, or edits [initial]: its title, secondary
|
||||||
|
/// title and colour. Completes with the new content once the person saves,
|
||||||
|
/// or with null when they close the sheet; closing it with changes asks
|
||||||
|
/// first.
|
||||||
|
///
|
||||||
|
/// What the sheet doesn't show (the description, the licence, the
|
||||||
|
/// languages) keeps its value.
|
||||||
|
Future<StudysetContent?> showStudysetSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
StudysetContent? initial,
|
||||||
|
}) => showAdaptiveSheet<StudysetContent>(
|
||||||
|
context,
|
||||||
|
// Swiping would discard changes without asking.
|
||||||
|
swipeable: false,
|
||||||
|
builder: (context) => _StudysetSheet(initial: initial),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// A new studyset's colour: the brand's cobalt.
|
||||||
|
const _defaultColor = PaletteColor.cobalt;
|
||||||
|
|
||||||
|
class const _StudysetSheet({required final StudysetContent? initial})
|
||||||
|
extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_StudysetSheet> createState() => _StudysetSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StudysetSheetState() extends State<_StudysetSheet> {
|
||||||
|
late final _title = TextEditingController(text: widget.initial?.title);
|
||||||
|
late final _secondaryTitle = TextEditingController(
|
||||||
|
text: widget.initial?.secondaryTitle,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The colour the person chose here; null until they choose one. Until
|
||||||
|
/// then a studyset keeps the colour it has, even none, or one only a newer
|
||||||
|
/// app version knows.
|
||||||
|
PaletteColor? _chosenColor;
|
||||||
|
|
||||||
|
/// The colour's name as the content holds it.
|
||||||
|
String? get _colorName =>
|
||||||
|
_chosenColor?.name ??
|
||||||
|
switch (widget.initial) {
|
||||||
|
null => _defaultColor.name,
|
||||||
|
final initial => initial.colorName,
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// The Create or Save button follows the title.
|
||||||
|
_title.addListener(() => setState(() {}));
|
||||||
|
_secondaryTitle.addListener(() => setState(() {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_title.dispose();
|
||||||
|
_secondaryTitle.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The content as the sheet holds it.
|
||||||
|
StudysetContent get _content {
|
||||||
|
final initial = widget.initial;
|
||||||
|
final secondaryTitle = _secondaryTitle.text.trim();
|
||||||
|
return StudysetContent(
|
||||||
|
title: _title.text.trim(),
|
||||||
|
description: initial?.description ?? RichContent.empty(),
|
||||||
|
secondaryTitle: secondaryTitle.isEmpty ? null : secondaryTitle,
|
||||||
|
colorName: _colorName,
|
||||||
|
license: initial?.license,
|
||||||
|
licenseHolder: initial?.licenseHolder,
|
||||||
|
licenseYear: initial?.licenseYear,
|
||||||
|
frontLanguage: initial?.frontLanguage,
|
||||||
|
backLanguage: initial?.backLanguage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _hasChanges {
|
||||||
|
final initial = widget.initial;
|
||||||
|
return initial == null
|
||||||
|
? _title.text.trim().isNotEmpty ||
|
||||||
|
_secondaryTitle.text.trim().isNotEmpty ||
|
||||||
|
_colorName != _defaultColor.name
|
||||||
|
: _content != initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final creating = widget.initial == null;
|
||||||
|
return PopScope<StudysetContent>(
|
||||||
|
canPop: !_hasChanges,
|
||||||
|
onPopInvokedWithResult: (didPop, _) async {
|
||||||
|
if (didPop) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
if (await confirmDestructiveAction(
|
||||||
|
context,
|
||||||
|
title: l10n.studysetSheetDiscardTitle,
|
||||||
|
message: l10n.studysetSheetDiscardMessage,
|
||||||
|
confirmLabel: l10n.studysetSheetDiscardConfirm,
|
||||||
|
)) {
|
||||||
|
navigator.pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: SheetScaffold(
|
||||||
|
title: creating
|
||||||
|
? l10n.studysetSheetCreateTitle
|
||||||
|
: l10n.studysetSheetEditTitle,
|
||||||
|
footer: FilledButton(
|
||||||
|
onPressed: _title.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(context).pop(_content),
|
||||||
|
child: Text(
|
||||||
|
creating ? l10n.studysetSheetCreate : l10n.studysetSheetSave,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _title,
|
||||||
|
// No keyboard popping up as the sheet opens: the person taps
|
||||||
|
// the field when ready.
|
||||||
|
textCapitalization: TextCapitalization.sentences,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.studysetSheetTitleLabel,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _secondaryTitle,
|
||||||
|
// Every surface shows it in capitals, so it is typed that way.
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
inputFormatters: const [_UpperCase()],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.studysetSheetSecondaryLabel,
|
||||||
|
helperText: l10n.studysetSheetSecondaryHelper,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(l10n.studysetSheetColor, style: theme.textTheme.labelLarge),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
PaletteColorPicker(
|
||||||
|
selected:
|
||||||
|
PaletteColor.byName(_colorName) ??
|
||||||
|
PaletteColor.byName(widget.initial?.colorName) ??
|
||||||
|
_defaultColor,
|
||||||
|
onChanged: (color) => setState(() => _chosenColor = color),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns what is typed into capitals.
|
||||||
|
class const _UpperCase() extends TextInputFormatter {
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(
|
||||||
|
TextEditingValue oldValue,
|
||||||
|
TextEditingValue newValue,
|
||||||
|
) => newValue.copyWith(text: newValue.text.toUpperCase());
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,23 @@ void main() {
|
||||||
(card.content.front.editorJson['document']! as Map)['note']! as String,
|
(card.content.front.editorJson['document']! as Map)['note']! as String,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
test('tells of every change, made here or synced from elsewhere', () async {
|
||||||
|
var changes = 0;
|
||||||
|
final subscription = repository.changes.listen((_) => changes++);
|
||||||
|
addTearDown(subscription.cancel);
|
||||||
|
|
||||||
|
final id = await repository.create('japanese', card('猫', 'cat'));
|
||||||
|
gateway.receiveRemoteRevision(
|
||||||
|
id: 'rev-9000',
|
||||||
|
cardId: id,
|
||||||
|
parents: (await repository.cardsOf('japanese')).single.heads,
|
||||||
|
payload: encodeCardPayload(card('猫', 'a cat')),
|
||||||
|
);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(changes, 2);
|
||||||
|
});
|
||||||
|
|
||||||
test('creates cards at the end of their studyset, in order', () async {
|
test('creates cards at the end of their studyset, in order', () async {
|
||||||
await repository.create('japanese', card('猫', 'cat'));
|
await repository.create('japanese', card('猫', 'cat'));
|
||||||
await repository.create('japanese', card('犬', 'dog'));
|
await repository.create('japanese', card('犬', 'dog'));
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_edit_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||||
|
|
||||||
|
import '../../../support/fake_card_gateway.dart';
|
||||||
|
import '../../../support/fake_studyset_gateway.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late FakeStudysetGateway gateway;
|
||||||
|
late StudysetRepository repository;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
gateway = FakeStudysetGateway();
|
||||||
|
repository = StudysetRepository(gateway);
|
||||||
|
});
|
||||||
|
tearDown(() => repository.dispose());
|
||||||
|
|
||||||
|
group('StudysetEditBloc', () {
|
||||||
|
late FakeCardGateway cardGateway;
|
||||||
|
late CardRepository cards;
|
||||||
|
late Studyset studyset;
|
||||||
|
late StudysetEditBloc bloc;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
cardGateway = FakeCardGateway();
|
||||||
|
cards = CardRepository(cardGateway);
|
||||||
|
final id = await repository.create(StudysetContent.titled('Japanese'));
|
||||||
|
studyset = Studyset(id: id, content: StudysetContent.titled('Japanese'));
|
||||||
|
bloc = StudysetEditBloc(repository, cards, studyset);
|
||||||
|
});
|
||||||
|
tearDown(() async {
|
||||||
|
await bloc.close();
|
||||||
|
await cards.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting the studyset blanks and deletes its cards too', () async {
|
||||||
|
final card = await cards.create(studyset.id, CardContent.empty());
|
||||||
|
|
||||||
|
bloc.add(const StudysetEditEventDeleteRequested());
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
bloc.stream,
|
||||||
|
emits(
|
||||||
|
isA<StudysetEditState>().having((s) => s.isDeleted, 'deleted', true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(cardGateway.deletions, ['blank $card', 'delete $card']);
|
||||||
|
expect(await cards.cardsOf(studyset.id), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps changes unsaved until asked to save', () async {
|
||||||
|
bloc.add(const StudysetEditEventTitleChanged('日本語'));
|
||||||
|
await expectLater(
|
||||||
|
bloc.stream,
|
||||||
|
emits(
|
||||||
|
isA<StudysetEditState>().having(
|
||||||
|
(s) => s.hasChanges,
|
||||||
|
'has changes',
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await repository.watchAll().first).single.content.title,
|
||||||
|
'Japanese',
|
||||||
|
);
|
||||||
|
|
||||||
|
bloc.add(const StudysetEditEventSaveRequested());
|
||||||
|
await expectLater(
|
||||||
|
bloc.stream,
|
||||||
|
emits(
|
||||||
|
isA<StudysetEditState>().having(
|
||||||
|
(s) => s.hasChanges,
|
||||||
|
'has changes',
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect((await repository.watchAll().first).single.content.title, '日本語');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletes the studyset and says so', () async {
|
||||||
|
bloc.add(const StudysetEditEventDeleteRequested());
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
bloc.stream,
|
||||||
|
emits(
|
||||||
|
isA<StudysetEditState>().having((s) => s.isDeleted, 'deleted', true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(await repository.watchAll().first, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,192 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.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 '../../../support/fake_card_gateway.dart';
|
||||||
|
import '../../../support/fake_review_gateway.dart';
|
||||||
|
import '../../../support/fake_studyset_gateway.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late FakeStudysetGateway gateway;
|
||||||
|
late StudysetRepository studysets;
|
||||||
|
late FakeCardGateway cardGateway;
|
||||||
|
late CardRepository cards;
|
||||||
|
late StudysetListBloc bloc;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
gateway = FakeStudysetGateway();
|
||||||
|
studysets = StudysetRepository(gateway);
|
||||||
|
cardGateway = FakeCardGateway();
|
||||||
|
cards = CardRepository(cardGateway);
|
||||||
|
bloc = StudysetListBloc(
|
||||||
|
studysets,
|
||||||
|
cards,
|
||||||
|
ReviewRepository(FakeReviewGateway()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
tearDown(() async {
|
||||||
|
await bloc.close();
|
||||||
|
await studysets.dispose();
|
||||||
|
await cards.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The next state that lists [titles], each studyset with its progress.
|
||||||
|
Future<StudysetListStateLoaded> listing(List<String> titles) async {
|
||||||
|
bool matches(StudysetListState state) =>
|
||||||
|
state is StudysetListStateLoaded &&
|
||||||
|
state.studysets.map((studyset) => studyset.content.title).join('|') ==
|
||||||
|
titles.join('|') &&
|
||||||
|
state.studysets.every(
|
||||||
|
(studyset) => state.progress.containsKey(studyset.id),
|
||||||
|
);
|
||||||
|
if (matches(bloc.state)) {
|
||||||
|
return bloc.state as StudysetListStateLoaded;
|
||||||
|
}
|
||||||
|
return await bloc.stream.firstWhere(matches) as StudysetListStateLoaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
CardContent newCard() => CardContent(
|
||||||
|
front: RichContent.empty(),
|
||||||
|
back: RichContent.empty(),
|
||||||
|
);
|
||||||
|
|
||||||
|
test("lists the studysets, each with its cards' progress", () async {
|
||||||
|
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||||
|
final german = await studysets.create(StudysetContent.titled('German'));
|
||||||
|
await cards.create(japanese, newCard());
|
||||||
|
|
||||||
|
bloc.add(const StudysetListEventStarted());
|
||||||
|
final state = await listing(['Japanese', 'German']);
|
||||||
|
|
||||||
|
expect(state.progress, {
|
||||||
|
japanese: const StudyProgress(cards: 1, due: 1, reviewsDue: 0, mature: 0),
|
||||||
|
german: const StudyProgress(cards: 0, due: 0, reviewsDue: 0, mature: 0),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lists the starred studysets first', () async {
|
||||||
|
await studysets.create(StudysetContent.titled('Japanese'));
|
||||||
|
final german = await studysets.create(StudysetContent.titled('German'));
|
||||||
|
await studysets.star(german);
|
||||||
|
|
||||||
|
bloc.add(const StudysetListEventStarted());
|
||||||
|
|
||||||
|
await listing(['German', 'Japanese']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lists only the top level', () async {
|
||||||
|
await studysets.create(StudysetContent.titled('Japanese'));
|
||||||
|
gateway.receiveRemoteChange(
|
||||||
|
'kana',
|
||||||
|
encodeStudysetPayload(StudysetContent.titled('Kana')),
|
||||||
|
parentId: 'studyset-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
bloc.add(const StudysetListEventStarted());
|
||||||
|
|
||||||
|
await listing(['Japanese']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('follows what is created, and the progress as cards change', () async {
|
||||||
|
bloc.add(const StudysetListEventStarted());
|
||||||
|
await listing([]);
|
||||||
|
|
||||||
|
bloc.add(
|
||||||
|
StudysetListEventCreateRequested(StudysetContent.titled('Japanese')),
|
||||||
|
);
|
||||||
|
final created = await listing(['Japanese']);
|
||||||
|
final id = created.studysets.single.id;
|
||||||
|
expect(created.progress[id]?.cards, 0);
|
||||||
|
|
||||||
|
await cards.create(id, newCard());
|
||||||
|
|
||||||
|
await bloc.stream.firstWhere(
|
||||||
|
(state) =>
|
||||||
|
state is StudysetListStateLoaded && state.progress[id]?.cards == 1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changes a studyset's content", () async {
|
||||||
|
final id = await studysets.create(StudysetContent.titled('Japanese'));
|
||||||
|
bloc.add(const StudysetListEventStarted());
|
||||||
|
await listing(['Japanese']);
|
||||||
|
|
||||||
|
bloc.add(
|
||||||
|
StudysetListEventContentChanged(id, StudysetContent.titled('日本語')),
|
||||||
|
);
|
||||||
|
|
||||||
|
await listing(['日本語']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('says so when the studysets cannot be read, then recovers', () async {
|
||||||
|
gateway.failReads = Exception('Disk unavailable');
|
||||||
|
bloc.add(const StudysetListEventStarted());
|
||||||
|
|
||||||
|
await expectLater(bloc.stream, emits(const StudysetListStateFailed()));
|
||||||
|
gateway.failReads = null;
|
||||||
|
await studysets.create(StudysetContent.titled('Japanese'));
|
||||||
|
|
||||||
|
await listing(['Japanese']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stars a studyset and takes the star away', () async {
|
||||||
|
await studysets.create(StudysetContent.titled('Japanese'));
|
||||||
|
bloc.add(const StudysetListEventStarted());
|
||||||
|
final studyset = (await listing(['Japanese'])).studysets.single;
|
||||||
|
|
||||||
|
bloc.add(StudysetListEventStarToggled(studyset));
|
||||||
|
final starred = await bloc.stream.firstWhere(
|
||||||
|
(state) =>
|
||||||
|
state is StudysetListStateLoaded && state.studysets.single.isStarred,
|
||||||
|
);
|
||||||
|
|
||||||
|
bloc.add(
|
||||||
|
StudysetListEventStarToggled(
|
||||||
|
(starred as StudysetListStateLoaded).studysets.single,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await bloc.stream.firstWhere(
|
||||||
|
(state) =>
|
||||||
|
state is StudysetListStateLoaded && !state.studysets.single.isStarred,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('archives a studyset and takes it out again', () async {
|
||||||
|
final id = await studysets.create(StudysetContent.titled('Japanese'));
|
||||||
|
bloc.add(const StudysetListEventStarted());
|
||||||
|
await listing(['Japanese']);
|
||||||
|
|
||||||
|
bloc.add(StudysetListEventArchiveRequested(id));
|
||||||
|
await bloc.stream.firstWhere(
|
||||||
|
(state) =>
|
||||||
|
state is StudysetListStateLoaded && state.studysets.single.isArchived,
|
||||||
|
);
|
||||||
|
bloc.add(StudysetListEventUnarchiveRequested(id));
|
||||||
|
|
||||||
|
await bloc.stream.firstWhere(
|
||||||
|
(state) =>
|
||||||
|
state is StudysetListStateLoaded &&
|
||||||
|
!state.studysets.single.isArchived,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletes a studyset with its cards, each blanked first', () async {
|
||||||
|
final id = await studysets.create(StudysetContent.titled('Japanese'));
|
||||||
|
final card = await cards.create(id, newCard());
|
||||||
|
bloc.add(const StudysetListEventStarted());
|
||||||
|
await listing(['Japanese']);
|
||||||
|
|
||||||
|
bloc.add(StudysetListEventDeleteRequested(id));
|
||||||
|
|
||||||
|
await listing([]);
|
||||||
|
expect(cardGateway.deletions, ['blank $card', 'delete $card']);
|
||||||
|
expect(gateway.deleted, [id]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||||
|
|
||||||
|
Studyset studyset(String id, {String? parent}) => Studyset(
|
||||||
|
id: id,
|
||||||
|
content: StudysetContent.titled(id),
|
||||||
|
parentId: parent,
|
||||||
|
);
|
||||||
|
|
||||||
|
Iterable<String> ids(List<Studyset> studysets) =>
|
||||||
|
studysets.map((studyset) => studyset.id);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('the top level holds the studysets without a parent, in their '
|
||||||
|
'order', () {
|
||||||
|
final all = [
|
||||||
|
studyset('japanese'),
|
||||||
|
studyset('kana', parent: 'japanese'),
|
||||||
|
studyset('german'),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(ids(topLevel(all)), ['japanese', 'german']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a studyset whose parent is gone shows at the top level, with what '
|
||||||
|
'is inside it', () {
|
||||||
|
final all = [
|
||||||
|
studyset('kana', parent: 'deleted'),
|
||||||
|
studyset('hiragana', parent: 'kana'),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(ids(topLevel(all)), ['kana']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('studysets moved into each other show at the top level; what is '
|
||||||
|
'inside them stays inside', () {
|
||||||
|
// Two devices moved a into b and b into a at once.
|
||||||
|
final all = [
|
||||||
|
studyset('a', parent: 'b'),
|
||||||
|
studyset('b', parent: 'a'),
|
||||||
|
studyset('c', parent: 'a'),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(ids(topLevel(all)), ['a', 'b']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,396 @@
|
||||||
|
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/card/model/card_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:halkyon_learn_flutter/src/feature/studyset/widget/studyset_card.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
import '../../../support/fake_card_gateway.dart';
|
||||||
|
import '../../../support/fake_studyset_gateway.dart';
|
||||||
|
import '../../../support/pump_app.dart';
|
||||||
|
import '../../../support/test_dependencies.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late FakeStudysetGateway gateway;
|
||||||
|
late FakeCardGateway cardGateway;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
gateway = FakeStudysetGateway();
|
||||||
|
cardGateway = FakeCardGateway();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Shows the library in a window [width] wide.
|
||||||
|
Future<void> showLibrary(WidgetTester tester, {double width = 1200}) async {
|
||||||
|
tester.view.physicalSize = Size(width, 1600);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await pumpApp(
|
||||||
|
tester,
|
||||||
|
testDependencies(studysetGateway: gateway, cardGateway: cardGateway),
|
||||||
|
location: RoutePaths.library,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A studyset on this device before the app starts.
|
||||||
|
void stored(
|
||||||
|
String id,
|
||||||
|
String title, {
|
||||||
|
String? secondaryTitle,
|
||||||
|
DateTime? archivedAt,
|
||||||
|
DateTime? starredAt,
|
||||||
|
}) => gateway.receiveRemoteChange(
|
||||||
|
id,
|
||||||
|
encodeStudysetPayload(
|
||||||
|
StudysetContent(
|
||||||
|
title: title,
|
||||||
|
description: RichContent.empty(),
|
||||||
|
secondaryTitle: secondaryTitle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
archivedAt: archivedAt,
|
||||||
|
starredAt: starredAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// [finder] within the card of the studyset titled [title].
|
||||||
|
Finder onCard(String title, Finder finder) => find.descendant(
|
||||||
|
of: find.widgetWithText(StudysetCard, title),
|
||||||
|
matching: finder,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Chooses [option] in the options of the studyset titled [title].
|
||||||
|
Future<void> choose(WidgetTester tester, String title, String option) async {
|
||||||
|
await tester.tap(onCard(title, find.byTooltip('Options')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text(option));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('says when there are no studysets yet', (tester) async {
|
||||||
|
await showLibrary(tester);
|
||||||
|
|
||||||
|
expect(find.text('No studysets yet'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('says so when the studysets cannot be read', (tester) async {
|
||||||
|
gateway.failReads = Exception('Disk unavailable');
|
||||||
|
|
||||||
|
await showLibrary(tester);
|
||||||
|
|
||||||
|
expect(find.text("Couldn't read your studysets."), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('creates a studyset with a secondary title and a colour', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final semantics = tester.ensureSemantics();
|
||||||
|
await showLibrary(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.text('New studyset'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Create studyset'), findsOneWidget);
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextField, 'Title'),
|
||||||
|
'Vocabulary',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextField, 'Secondary title'),
|
||||||
|
'japanese',
|
||||||
|
);
|
||||||
|
await tester.tap(find.bySemanticsLabel('Color: Cobalt'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.bySemanticsLabel('Fern'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Create'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('JAPANESE'), findsOneWidget);
|
||||||
|
expect(find.text('Vocabulary'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
decodeStudysetPayload(gateway.writes.single.$2),
|
||||||
|
StudysetContent(
|
||||||
|
title: 'Vocabulary',
|
||||||
|
description: RichContent.empty(),
|
||||||
|
secondaryTitle: 'JAPANESE',
|
||||||
|
colorName: 'fern',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
semantics.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a new studyset is cobalt unless another colour is chosen', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await showLibrary(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.text('New studyset'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.widgetWithText(TextField, 'Title'), 'Kanji');
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Create'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
decodeStudysetPayload(gateway.writes.single.$2).colorName,
|
||||||
|
'cobalt',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('creating waits for a title', (tester) async {
|
||||||
|
await showLibrary(tester);
|
||||||
|
await tester.tap(find.text('New studyset'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
FilledButton create() => tester.widget<FilledButton>(
|
||||||
|
find.ancestor(
|
||||||
|
of: find.text('Create'),
|
||||||
|
matching: find.byType(FilledButton),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(create().onPressed, isNull);
|
||||||
|
|
||||||
|
await tester.enterText(find.widgetWithText(TextField, 'Title'), ' ');
|
||||||
|
await tester.pump();
|
||||||
|
expect(create().onPressed, isNull);
|
||||||
|
|
||||||
|
await tester.enterText(find.widgetWithText(TextField, 'Title'), 'Kanji');
|
||||||
|
await tester.pump();
|
||||||
|
expect(create().onPressed, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('closing the sheet with changes asks first; staying keeps '
|
||||||
|
'them, discarding closes it', (tester) async {
|
||||||
|
await showLibrary(tester);
|
||||||
|
await tester.tap(find.text('New studyset'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.widgetWithText(TextField, 'Title'), 'Kanji');
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Close'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Discard changes?'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Cancel'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Create studyset'), findsOneWidget);
|
||||||
|
expect(find.text('Kanji'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Close'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Discard'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Create studyset'), findsNothing);
|
||||||
|
expect(gateway.writes, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets("the bottom sheet doesn't swipe away, which would lose "
|
||||||
|
'changes without asking; a tap beside it asks', (tester) async {
|
||||||
|
await showLibrary(tester, width: 400);
|
||||||
|
await tester.tap(find.byTooltip('New studyset'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.widgetWithText(TextField, 'Title'), 'Kanji');
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.fling(
|
||||||
|
find.text('Create studyset'),
|
||||||
|
const Offset(0, 600),
|
||||||
|
2000,
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Create studyset'), findsOneWidget);
|
||||||
|
expect(find.text('Kanji'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tapAt(const Offset(200, 20));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Discard changes?'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('closing an untouched sheet asks nothing', (tester) async {
|
||||||
|
await showLibrary(tester);
|
||||||
|
await tester.tap(find.text('New studyset'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Close'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Discard changes?'), findsNothing);
|
||||||
|
expect(find.text('Create studyset'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('edits a studyset from its options', (tester) async {
|
||||||
|
stored('japanese', 'Japanese');
|
||||||
|
await showLibrary(tester);
|
||||||
|
|
||||||
|
await choose(tester, 'Japanese', 'Edit');
|
||||||
|
expect(find.text('Edit studyset'), findsOneWidget);
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextField, 'Japanese'),
|
||||||
|
'日本語',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Save'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('日本語'), findsOneWidget);
|
||||||
|
expect(find.text('Japanese'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('lists starred studysets first', (tester) async {
|
||||||
|
stored('japanese', 'Japanese');
|
||||||
|
stored('german', 'German', starredAt: DateTime.utc(2026, 10));
|
||||||
|
|
||||||
|
await showLibrary(tester);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
tester.getTopLeft(find.text('German')).dx,
|
||||||
|
lessThan(tester.getTopLeft(find.text('Japanese')).dx),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('archiving tucks a studyset into the closed archive; Undo '
|
||||||
|
'brings it back', (tester) async {
|
||||||
|
stored('japanese', 'Japanese');
|
||||||
|
stored('german', 'German');
|
||||||
|
await showLibrary(tester);
|
||||||
|
|
||||||
|
await choose(tester, 'Japanese', 'Archive');
|
||||||
|
expect(find.text('Studyset archived'), findsOneWidget);
|
||||||
|
expect(find.text('Japanese'), findsNothing);
|
||||||
|
expect(find.text('Archived'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Undo'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Japanese'), findsOneWidget);
|
||||||
|
expect(find.text('Archived'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the archive opens to show what rests there, which can be '
|
||||||
|
'unarchived', (tester) async {
|
||||||
|
stored('japanese', 'Japanese');
|
||||||
|
stored('kanji', 'Kanji', archivedAt: DateTime.utc(2026, 10));
|
||||||
|
await showLibrary(tester);
|
||||||
|
expect(find.text('Kanji'), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Archived'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Kanji'), findsOneWidget);
|
||||||
|
await choose(tester, 'Kanji', 'Unarchive');
|
||||||
|
|
||||||
|
expect(find.text('Studyset unarchived'), findsOneWidget);
|
||||||
|
expect(find.text('Archived'), findsNothing);
|
||||||
|
expect(find.text('Kanji'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('searches titles and secondary titles, the archive too', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
stored('vocabulary', 'Vocabulary', secondaryTitle: 'Japanese');
|
||||||
|
stored('grammar', 'Grammar');
|
||||||
|
stored(
|
||||||
|
'kanji',
|
||||||
|
'Kanji',
|
||||||
|
secondaryTitle: 'Japanese',
|
||||||
|
archivedAt: DateTime.utc(2026, 10),
|
||||||
|
);
|
||||||
|
await showLibrary(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Search'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byType(TextField), 'japan');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Vocabulary'), findsOneWidget);
|
||||||
|
expect(find.text('Kanji'), findsOneWidget);
|
||||||
|
expect(find.text('Grammar'), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Close search'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Grammar'), findsOneWidget);
|
||||||
|
expect(find.text('Kanji'), findsNothing, reason: 'the archive is closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('says when no studyset matches the search', (tester) async {
|
||||||
|
stored('japanese', 'Japanese');
|
||||||
|
await showLibrary(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Search'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byType(TextField), 'swahili');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('No studysets match your search'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('deleting asks first; Cancel keeps the studyset', (tester) async {
|
||||||
|
stored('japanese', 'Japanese');
|
||||||
|
await showLibrary(tester);
|
||||||
|
|
||||||
|
await choose(tester, 'Japanese', 'Delete');
|
||||||
|
expect(find.text('Delete studyset?'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Cancel'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Japanese'), findsOneWidget);
|
||||||
|
expect(gateway.deleted, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a confirmed delete removes the studyset with its cards', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
stored('japanese', 'Japanese');
|
||||||
|
final cards = CardRepository(cardGateway);
|
||||||
|
addTearDown(cards.dispose);
|
||||||
|
final card = await cards.create(
|
||||||
|
'japanese',
|
||||||
|
CardContent(front: RichContent.empty(), back: RichContent.empty()),
|
||||||
|
);
|
||||||
|
await showLibrary(tester);
|
||||||
|
|
||||||
|
await choose(tester, 'Japanese', 'Delete');
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('No studysets yet'), findsOneWidget);
|
||||||
|
expect(gateway.deleted, ['japanese']);
|
||||||
|
expect(cardGateway.deletions, ['blank $card', 'delete $card']);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a card invites studying its due cards, and counts anew after '
|
||||||
|
'studying', (tester) async {
|
||||||
|
stored('japanese', 'Japanese');
|
||||||
|
final cards = CardRepository(cardGateway);
|
||||||
|
addTearDown(cards.dispose);
|
||||||
|
await cards.create(
|
||||||
|
'japanese',
|
||||||
|
CardContent(front: RichContent.empty(), back: RichContent.empty()),
|
||||||
|
);
|
||||||
|
await showLibrary(tester);
|
||||||
|
expect(find.text('1 flashcard'), findsOneWidget);
|
||||||
|
expect(onCard('Japanese', find.text('1')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Study'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Show answer'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Easy'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.pageBack();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byTooltip('Add content'), findsOneWidget);
|
||||||
|
expect(onCard('Japanese', find.text('1')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the create button floats in compact windows and sits in the '
|
||||||
|
'app bar from medium on', (tester) async {
|
||||||
|
await showLibrary(tester, width: 599);
|
||||||
|
expect(find.byType(FloatingActionButton), findsOneWidget);
|
||||||
|
expect(find.text('New studyset'), findsNothing);
|
||||||
|
|
||||||
|
await showLibrary(tester, width: 600);
|
||||||
|
expect(find.byType(FloatingActionButton), findsNothing);
|
||||||
|
expect(find.text('New studyset'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
import '../../../support/fake_auth_gateway.dart';
|
||||||
|
import '../../../support/fake_studyset_gateway.dart';
|
||||||
|
import '../../../support/pump_app.dart';
|
||||||
|
import '../../../support/test_dependencies.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late FakeStudysetGateway gateway;
|
||||||
|
|
||||||
|
/// Shows the app at [location], the library unless given; every person
|
||||||
|
/// shares [gateway] unless [auth] is given, in which case each person gets
|
||||||
|
/// data of their own.
|
||||||
|
Future<void> showApp(
|
||||||
|
WidgetTester tester, {
|
||||||
|
FakeAuthGateway? auth,
|
||||||
|
String location = RoutePaths.library,
|
||||||
|
}) async {
|
||||||
|
tester.view.physicalSize = const Size(1200, 1600);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
await pumpApp(
|
||||||
|
tester,
|
||||||
|
auth == null
|
||||||
|
? testDependencies(studysetGateway: gateway)
|
||||||
|
: testDependencies(authGateway: auth),
|
||||||
|
location: location,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a studyset titled [title] in the library's sheet.
|
||||||
|
Future<void> create(WidgetTester tester, String title) async {
|
||||||
|
await tester.tap(find.text('New studyset'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.widgetWithText(TextField, 'Title'), title);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('Create'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> open(WidgetTester tester, String title) async {
|
||||||
|
await tester.tap(find.text(title));
|
||||||
|
await tester.pump();
|
||||||
|
// Flushes the editor's timers.
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
setUp(() => gateway = FakeStudysetGateway());
|
||||||
|
|
||||||
|
testWidgets('saves a changed title', (tester) async {
|
||||||
|
await showApp(tester);
|
||||||
|
await create(tester, 'Japanese');
|
||||||
|
await open(tester, 'Japanese');
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.widgetWithText(TextFormField, 'Japanese'),
|
||||||
|
'日本語',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byTooltip('Save'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pageBack();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('日本語'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('deleting asks first; Cancel keeps the studyset', (tester) async {
|
||||||
|
await showApp(tester);
|
||||||
|
await create(tester, 'Japanese');
|
||||||
|
await open(tester, 'Japanese');
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Delete'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Delete this studyset?'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Cancel'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(gateway.deleted, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('an open studyset closes when the person changes, and the next '
|
||||||
|
'person sees none of it', (tester) async {
|
||||||
|
final auth = FakeAuthGateway();
|
||||||
|
await showApp(tester, auth: auth);
|
||||||
|
await create(tester, 'Japanese');
|
||||||
|
await open(tester, 'Japanese');
|
||||||
|
expect(find.byType(StudysetScreen), findsOneWidget);
|
||||||
|
|
||||||
|
auth.revokeSession();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(StudysetScreen), findsNothing);
|
||||||
|
expect(find.text('Japanese'), findsNothing);
|
||||||
|
expect(find.text('No studysets yet'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a confirmed delete removes the studyset and closes it, '
|
||||||
|
'without it showing as gone on its way out', (tester) async {
|
||||||
|
await showApp(tester);
|
||||||
|
await create(tester, 'Japanese');
|
||||||
|
await open(tester, 'Japanese');
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Delete'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Delete'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
expect(find.textContaining('no longer exists'), findsNothing);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(gateway.deleted, hasLength(1));
|
||||||
|
expect(find.text('No studysets yet'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('an open studyset deleted on another device says so', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await showApp(tester);
|
||||||
|
await create(tester, 'Japanese');
|
||||||
|
await open(tester, 'Japanese');
|
||||||
|
|
||||||
|
gateway.receiveRemoteDeletion('studyset-1');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(StudysetScreen), findsNothing);
|
||||||
|
expect(find.textContaining('no longer exists'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a studyset that does not exist says so', (tester) async {
|
||||||
|
await showApp(tester, location: RoutePaths.studyset('studyset-9'));
|
||||||
|
|
||||||
|
expect(find.textContaining('no longer exists'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,6 @@ void main() {
|
||||||
await tester.tap(find.text('Open library'));
|
await tester.tap(find.text('Open library'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('No studysets yet.'), findsOneWidget);
|
expect(find.text('No studysets yet'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue