feat(app): tag, edit, delete and swipe cards while studying, with tidier session sheets
- Above every studied card (but a matching one): the PDF it came from,
its tags on one line ("+2 tags" for the rest) or "Add tags", and a
menu to tag, edit or delete it; the session follows (an edited card
shows as saved, a deleted one leaves the round).
- On phones, a front/back or vocabulary card swiped right is rated
Good, left Bad; it tilts over the colour it heads for, and springs
back when let go early.
- The session's settings: "Settings", a 48 dp Save that warns mid-round
("Heads up!"), steppers in an outlined box ("20 / day", "5 / 50"),
the order in an outlined dropdown, the chosen tags with the rest
behind "Filter by tags" / "+3 more".
- A studyset's study options: Save above Reset to defaults, the
description small, reviews a day in steps of 25.
- One stepper look everywhere (study, Pomodoro profiles), its buttons
"Decrease" and "Increase".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
369ce4ade1
commit
b0aaecf115
20 changed files with 1109 additions and 220 deletions
|
|
@ -97,7 +97,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
- **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved.
|
||||
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below, and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and a studyset's topic opens on top of it with `context.push` (the same route, keyed by id, so several can stack); the breadcrumb's steps go up with `context.go`, so back then leads further up. What needs the whole window (studying, a card, settings) is a top-level route over the tabs, reached with `context.push`. Explore stays empty until something is shared.
|
||||
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
|
||||
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star, which needs the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, and the sheets below).
|
||||
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, and the sheets below).
|
||||
- **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none.
|
||||
- **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files.
|
||||
|
||||
|
|
|
|||
|
|
@ -329,13 +329,18 @@
|
|||
"tagDeleted": "Tag „{name}“ gelöscht",
|
||||
"studySettingsTitle": "Sitzungseinstellungen",
|
||||
"studySettingsFilterByTags": "Nach Tags filtern",
|
||||
"studySettingsFilterHint": "Lerne nur die Karteikarten mit mindestens einem der gewählten Tags.",
|
||||
"studySettingsNoTags": "Noch keine Tags. Füge deinen Karteikarten Tags hinzu, um sie hier zu filtern.",
|
||||
"studySettingsNoTags": "Noch keine Inhalts-Tags. Füge deinen Karten Tags hinzu, um sie hier zu filtern.",
|
||||
"studySettingsReset": "Zurücksetzen",
|
||||
"studySettingsSave": "Speichern",
|
||||
"studySettingsHeadsUpTitle": "Achtung!",
|
||||
"studySettingsHeadsUpBody": "Die vorgenommenen Änderungen laden die Sitzung neu und dein aktueller Fortschritt geht verloren.",
|
||||
"studySettingsApplyChanges": "Änderungen übernehmen",
|
||||
"studySettingsPerDay": " / Tag",
|
||||
"studySettingsMoreTags": "+{count} weitere",
|
||||
"cardTagsLabel": "Tags",
|
||||
"cardTagsAdd": "Tags hinzufügen",
|
||||
"cardTagsEdit": "Tags bearbeiten",
|
||||
"tagChipsOverflow": "+{count, plural, =1{1 Tag} other{{count} Tags}}",
|
||||
"cardFilterTooltip": "Filter",
|
||||
"cardFilterTitle": "Filter",
|
||||
"cardFilterTag": "Tag",
|
||||
|
|
@ -492,7 +497,6 @@
|
|||
"studyOptionsTitle": "Lernoptionen",
|
||||
"studyOptionsDescription": "Wie viele Karten dieses Lernset pro Tag bringt und welche zuerst kommen, auf all deinen Geräten. Die Lernsets darin behalten ihre eigenen.",
|
||||
"studyOptionsReviewsPerDay": "Wiederholungen pro Tag",
|
||||
"studyOptionsReviewsPerDayHint": "Karten in ihren Lernschritten kommen trotzdem.",
|
||||
"studyOptionsOrder": "Reihenfolge",
|
||||
"studyOptionsReviewsFirst": "Wiederholungen zuerst",
|
||||
"studyOptionsMixed": "Gemischt",
|
||||
|
|
@ -508,8 +512,8 @@
|
|||
"studySortOldestFirst": "Älteste zuerst",
|
||||
"studySortRandom": "Zufällig",
|
||||
"studySortNewestFirst": "Neueste zuerst",
|
||||
"studyFewer": "Weniger",
|
||||
"studyMore": "Mehr",
|
||||
"studyFewer": "Verringern",
|
||||
"studyMore": "Erhöhen",
|
||||
|
||||
"studysetMenuReset": "Fortschritt zurücksetzen",
|
||||
"studysetResetTitle": "Fortschritt zurücksetzen?",
|
||||
|
|
|
|||
|
|
@ -366,13 +366,20 @@
|
|||
"@tagDeleted": {"placeholders": {"name": {"type": "String"}}},
|
||||
"studySettingsTitle": "Session settings",
|
||||
"studySettingsFilterByTags": "Filter by tags",
|
||||
"studySettingsFilterHint": "Study only the flashcards with any of the tags chosen.",
|
||||
"studySettingsNoTags": "No tags yet. Add tags to your flashcards to filter them here.",
|
||||
"studySettingsNoTags": "No content tags yet. Add tags to your cards to filter them here.",
|
||||
"studySettingsReset": "Reset",
|
||||
"studySettingsSave": "Save",
|
||||
"studySettingsHeadsUpTitle": "Heads up!",
|
||||
"studySettingsHeadsUpBody": "The changes you’ve made will reload the session, and you’ll lose your current progress.",
|
||||
"studySettingsApplyChanges": "Apply changes",
|
||||
"studySettingsPerDay": " / day",
|
||||
"studySettingsMoreTags": "+{count} more",
|
||||
"@studySettingsMoreTags": {"placeholders": {"count": {"type": "int"}}},
|
||||
"cardTagsLabel": "Tags",
|
||||
"cardTagsAdd": "Add tags",
|
||||
"cardTagsEdit": "Edit tags",
|
||||
"tagChipsOverflow": "+{count, plural, =1{1 tag} other{{count} tags}}",
|
||||
"@tagChipsOverflow": {"placeholders": {"count": {"type": "int"}}},
|
||||
"cardFilterTooltip": "Filter",
|
||||
"cardFilterTitle": "Filter",
|
||||
"cardFilterTag": "Tag",
|
||||
|
|
@ -560,7 +567,6 @@
|
|||
"studyOptionsTitle": "Study options",
|
||||
"studyOptionsDescription": "How many cards this studyset brings a day, and which come first, on every device. The studysets inside it keep their own.",
|
||||
"studyOptionsReviewsPerDay": "Reviews per day",
|
||||
"studyOptionsReviewsPerDayHint": "Cards in their learning steps come anyway.",
|
||||
"studyOptionsOrder": "Order",
|
||||
"studyOptionsReviewsFirst": "Reviews first",
|
||||
"studyOptionsMixed": "Mixed",
|
||||
|
|
@ -576,8 +582,8 @@
|
|||
"studySortOldestFirst": "Oldest first",
|
||||
"studySortRandom": "Random",
|
||||
"studySortNewestFirst": "Newest first",
|
||||
"studyFewer": "Fewer",
|
||||
"studyMore": "More",
|
||||
"studyFewer": "Decrease",
|
||||
"studyMore": "Increase",
|
||||
|
||||
"studysetMenuReset": "Reset progress",
|
||||
"studysetResetTitle": "Reset progress?",
|
||||
|
|
|
|||
|
|
@ -2008,16 +2008,10 @@ abstract class AppLocalizations {
|
|||
/// **'Filter by tags'**
|
||||
String get studySettingsFilterByTags;
|
||||
|
||||
/// No description provided for @studySettingsFilterHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Study only the flashcards with any of the tags chosen.'**
|
||||
String get studySettingsFilterHint;
|
||||
|
||||
/// No description provided for @studySettingsNoTags.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No tags yet. Add tags to your flashcards to filter them here.'**
|
||||
/// **'No content tags yet. Add tags to your cards to filter them here.'**
|
||||
String get studySettingsNoTags;
|
||||
|
||||
/// No description provided for @studySettingsReset.
|
||||
|
|
@ -2032,6 +2026,36 @@ abstract class AppLocalizations {
|
|||
/// **'Save'**
|
||||
String get studySettingsSave;
|
||||
|
||||
/// No description provided for @studySettingsHeadsUpTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Heads up!'**
|
||||
String get studySettingsHeadsUpTitle;
|
||||
|
||||
/// No description provided for @studySettingsHeadsUpBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The changes you’ve made will reload the session, and you’ll lose your current progress.'**
|
||||
String get studySettingsHeadsUpBody;
|
||||
|
||||
/// No description provided for @studySettingsApplyChanges.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Apply changes'**
|
||||
String get studySettingsApplyChanges;
|
||||
|
||||
/// No description provided for @studySettingsPerDay.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **' / day'**
|
||||
String get studySettingsPerDay;
|
||||
|
||||
/// No description provided for @studySettingsMoreTags.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'+{count} more'**
|
||||
String studySettingsMoreTags(int count);
|
||||
|
||||
/// No description provided for @cardTagsLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -2050,6 +2074,12 @@ abstract class AppLocalizations {
|
|||
/// **'Edit tags'**
|
||||
String get cardTagsEdit;
|
||||
|
||||
/// No description provided for @tagChipsOverflow.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'+{count, plural, =1{1 tag} other{{count} tags}}'**
|
||||
String tagChipsOverflow(int count);
|
||||
|
||||
/// No description provided for @cardFilterTooltip.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -2968,12 +2998,6 @@ abstract class AppLocalizations {
|
|||
/// **'Reviews per day'**
|
||||
String get studyOptionsReviewsPerDay;
|
||||
|
||||
/// No description provided for @studyOptionsReviewsPerDayHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cards in their learning steps come anyway.'**
|
||||
String get studyOptionsReviewsPerDayHint;
|
||||
|
||||
/// No description provided for @studyOptionsOrder.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -3067,13 +3091,13 @@ abstract class AppLocalizations {
|
|||
/// No description provided for @studyFewer.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fewer'**
|
||||
/// **'Decrease'**
|
||||
String get studyFewer;
|
||||
|
||||
/// No description provided for @studyMore.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'More'**
|
||||
/// **'Increase'**
|
||||
String get studyMore;
|
||||
|
||||
/// No description provided for @studysetMenuReset.
|
||||
|
|
|
|||
|
|
@ -1166,13 +1166,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get studySettingsFilterByTags => 'Nach Tags filtern';
|
||||
|
||||
@override
|
||||
String get studySettingsFilterHint =>
|
||||
'Lerne nur die Karteikarten mit mindestens einem der gewählten Tags.';
|
||||
|
||||
@override
|
||||
String get studySettingsNoTags =>
|
||||
'Noch keine Tags. Füge deinen Karteikarten Tags hinzu, um sie hier zu filtern.';
|
||||
'Noch keine Inhalts-Tags. Füge deinen Karten Tags hinzu, um sie hier zu filtern.';
|
||||
|
||||
@override
|
||||
String get studySettingsReset => 'Zurücksetzen';
|
||||
|
|
@ -1180,6 +1176,24 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get studySettingsSave => 'Speichern';
|
||||
|
||||
@override
|
||||
String get studySettingsHeadsUpTitle => 'Achtung!';
|
||||
|
||||
@override
|
||||
String get studySettingsHeadsUpBody =>
|
||||
'Die vorgenommenen Änderungen laden die Sitzung neu und dein aktueller Fortschritt geht verloren.';
|
||||
|
||||
@override
|
||||
String get studySettingsApplyChanges => 'Änderungen übernehmen';
|
||||
|
||||
@override
|
||||
String get studySettingsPerDay => ' / Tag';
|
||||
|
||||
@override
|
||||
String studySettingsMoreTags(int count) {
|
||||
return '+$count weitere';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cardTagsLabel => 'Tags';
|
||||
|
||||
|
|
@ -1189,6 +1203,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get cardTagsEdit => 'Tags bearbeiten';
|
||||
|
||||
@override
|
||||
String tagChipsOverflow(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Tags',
|
||||
one: '1 Tag',
|
||||
);
|
||||
return '+$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cardFilterTooltip => 'Filter';
|
||||
|
||||
|
|
@ -1803,10 +1828,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get studyOptionsReviewsPerDay => 'Wiederholungen pro Tag';
|
||||
|
||||
@override
|
||||
String get studyOptionsReviewsPerDayHint =>
|
||||
'Karten in ihren Lernschritten kommen trotzdem.';
|
||||
|
||||
@override
|
||||
String get studyOptionsOrder => 'Reihenfolge';
|
||||
|
||||
|
|
@ -1855,10 +1876,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get studySortNewestFirst => 'Neueste zuerst';
|
||||
|
||||
@override
|
||||
String get studyFewer => 'Weniger';
|
||||
String get studyFewer => 'Verringern';
|
||||
|
||||
@override
|
||||
String get studyMore => 'Mehr';
|
||||
String get studyMore => 'Erhöhen';
|
||||
|
||||
@override
|
||||
String get studysetMenuReset => 'Fortschritt zurücksetzen';
|
||||
|
|
|
|||
|
|
@ -1157,13 +1157,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get studySettingsFilterByTags => 'Filter by tags';
|
||||
|
||||
@override
|
||||
String get studySettingsFilterHint =>
|
||||
'Study only the flashcards with any of the tags chosen.';
|
||||
|
||||
@override
|
||||
String get studySettingsNoTags =>
|
||||
'No tags yet. Add tags to your flashcards to filter them here.';
|
||||
'No content tags yet. Add tags to your cards to filter them here.';
|
||||
|
||||
@override
|
||||
String get studySettingsReset => 'Reset';
|
||||
|
|
@ -1171,6 +1167,24 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get studySettingsSave => 'Save';
|
||||
|
||||
@override
|
||||
String get studySettingsHeadsUpTitle => 'Heads up!';
|
||||
|
||||
@override
|
||||
String get studySettingsHeadsUpBody =>
|
||||
'The changes you’ve made will reload the session, and you’ll lose your current progress.';
|
||||
|
||||
@override
|
||||
String get studySettingsApplyChanges => 'Apply changes';
|
||||
|
||||
@override
|
||||
String get studySettingsPerDay => ' / day';
|
||||
|
||||
@override
|
||||
String studySettingsMoreTags(int count) {
|
||||
return '+$count more';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cardTagsLabel => 'Tags';
|
||||
|
||||
|
|
@ -1180,6 +1194,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get cardTagsEdit => 'Edit tags';
|
||||
|
||||
@override
|
||||
String tagChipsOverflow(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tags',
|
||||
one: '1 tag',
|
||||
);
|
||||
return '+$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cardFilterTooltip => 'Filter';
|
||||
|
||||
|
|
@ -1791,10 +1816,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get studyOptionsReviewsPerDay => 'Reviews per day';
|
||||
|
||||
@override
|
||||
String get studyOptionsReviewsPerDayHint =>
|
||||
'Cards in their learning steps come anyway.';
|
||||
|
||||
@override
|
||||
String get studyOptionsOrder => 'Order';
|
||||
|
||||
|
|
@ -1843,10 +1864,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get studySortNewestFirst => 'Newest first';
|
||||
|
||||
@override
|
||||
String get studyFewer => 'Fewer';
|
||||
String get studyFewer => 'Decrease';
|
||||
|
||||
@override
|
||||
String get studyMore => 'More';
|
||||
String get studyMore => 'Increase';
|
||||
|
||||
@override
|
||||
String get studysetMenuReset => 'Reset progress';
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_opt
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
|
||||
|
||||
/// A study session over a studyset with everything inside it, but for
|
||||
/// archived studysets (`studiedIn`), and of those cards the ones with any of
|
||||
|
|
@ -55,6 +57,9 @@ class StudySessionBloc(
|
|||
);
|
||||
on<StudySessionEventRated>(_onRated);
|
||||
on<StudySessionEventMoved>(_onMoved);
|
||||
on<StudySessionEventTagsChanged>(_onTagsChanged);
|
||||
on<StudySessionEventCardEdited>(_onCardEdited);
|
||||
on<StudySessionEventCardDeleted>(_onCardDeleted);
|
||||
on<StudySessionEventBackFirstToggled>((event, emit) async {
|
||||
// A focused session turns for itself; the settings stay as they are.
|
||||
if (_focus case final focus?) {
|
||||
|
|
@ -78,6 +83,12 @@ class StudySessionBloc(
|
|||
var _options = const StudyOptions();
|
||||
var _round = <String>[];
|
||||
var _cardsById = <String, Card>{};
|
||||
|
||||
/// The studyset each card of the session is in.
|
||||
final _studysetOf = <String, String>{};
|
||||
|
||||
/// The tags of the session's cards.
|
||||
var _tagging = Tagging.empty;
|
||||
final _ratings = <String, Rating>{};
|
||||
final _graduated = <String>{};
|
||||
var _cramIndex = 0;
|
||||
|
|
@ -120,6 +131,7 @@ class StudySessionBloc(
|
|||
for (final group in groups)
|
||||
for (final card in group) card.id: card,
|
||||
};
|
||||
_tagging = await _tags.read();
|
||||
final now = clock.now().toUtc();
|
||||
if (_options.mode == LearnMode.cram) {
|
||||
final all = [
|
||||
|
|
@ -189,13 +201,19 @@ class StudySessionBloc(
|
|||
optionsOf(id) ?? StudysetStudyOptions(newPerDay: _options.newPerDay),
|
||||
];
|
||||
_order = optionsOf(_studysetId)?.order ?? NewCardOrder.reviewsFirst;
|
||||
return [
|
||||
for (final studysetId in studied)
|
||||
[
|
||||
_studysetOf.clear();
|
||||
final groups = <List<Card>>[];
|
||||
for (final studysetId in studied) {
|
||||
final group = [
|
||||
for (final card in await _cards.cardsOf(studysetId))
|
||||
if (tagged == null || tagged.contains(card.id)) card,
|
||||
],
|
||||
];
|
||||
for (final card in group) {
|
||||
_studysetOf[card.id] = studysetId;
|
||||
}
|
||||
groups.add(group);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// [ids] in the order the settings ask for.
|
||||
|
|
@ -295,6 +313,8 @@ class StudySessionBloc(
|
|||
final card = _cardsById[id]!;
|
||||
return StudySessionStateShowing(
|
||||
card: card,
|
||||
studysetId: _studysetOf[id] ?? _studysetId,
|
||||
tags: _tagging.of(id),
|
||||
asksBack: _asksBack(card),
|
||||
schedule: schedule,
|
||||
shownAt: now,
|
||||
|
|
@ -423,6 +443,80 @@ class StudySessionBloc(
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _onTagsChanged(
|
||||
StudySessionEventTagsChanged event,
|
||||
Emitter<StudySessionState> emit,
|
||||
) async {
|
||||
if (state case final StudySessionStateShowing showing) {
|
||||
await _tags.setTagsOf(showing.card.id, event.tagIds);
|
||||
_tagging = await _tags.read();
|
||||
_reshow(emit, showing);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCardEdited(
|
||||
StudySessionEventCardEdited event,
|
||||
Emitter<StudySessionState> emit,
|
||||
) async {
|
||||
if (state case final StudySessionStateShowing showing) {
|
||||
final id = showing.card.id;
|
||||
final studysetId = _studysetOf[id] ?? _studysetId;
|
||||
final edited = (await _cards.cardsOf(
|
||||
studysetId,
|
||||
)).where((card) => card.id == id).firstOrNull;
|
||||
if (edited == null) {
|
||||
await _leave(emit, id);
|
||||
return;
|
||||
}
|
||||
_cardsById[id] = edited;
|
||||
_tagging = await _tags.read();
|
||||
_reshow(emit, showing);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCardDeleted(
|
||||
StudySessionEventCardDeleted event,
|
||||
Emitter<StudySessionState> emit,
|
||||
) async {
|
||||
if (state case final StudySessionStateShowing showing) {
|
||||
await _cards.delete(showing.card);
|
||||
await _leave(emit, showing.card.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the card on screen again as it now is, its answer as it was.
|
||||
void _reshow(
|
||||
Emitter<StudySessionState> emit,
|
||||
StudySessionStateShowing showing,
|
||||
) => emit(
|
||||
_showing(
|
||||
showing.card.id,
|
||||
showing.shownAt,
|
||||
schedule: showing.schedule,
|
||||
).withAnswerShown(showing.answerShown),
|
||||
);
|
||||
|
||||
/// Takes the card [id] out of the session, and shows the next.
|
||||
Future<void> _leave(Emitter<StudySessionState> emit, String id) async {
|
||||
final at = _round.indexOf(id);
|
||||
_round.remove(id);
|
||||
_cardsById.remove(id);
|
||||
_ratings.remove(id);
|
||||
_graduated.remove(id);
|
||||
final now = clock.now().toUtc();
|
||||
if (_options.mode != LearnMode.cram) {
|
||||
await _showNext(emit, now);
|
||||
return;
|
||||
}
|
||||
if (_round.isEmpty || _round.every(_ratings.containsKey)) {
|
||||
emit(await _roundDone());
|
||||
return;
|
||||
}
|
||||
// The card after it, which moved into its place.
|
||||
_cramIndex = (at < 0 ? _cramIndex : at) % _round.length;
|
||||
emit(_showing(_round[_cramIndex], now, schedule: null));
|
||||
}
|
||||
|
||||
void _onMoved(StudySessionEventMoved event, Emitter<StudySessionState> emit) {
|
||||
if (_options.mode != LearnMode.cram || state is! StudySessionStateShowing) {
|
||||
return;
|
||||
|
|
@ -466,6 +560,21 @@ final class const StudySessionEventRated(
|
|||
List<Object?> get props => [rating];
|
||||
}
|
||||
|
||||
/// Tag the card on screen with the tags of [tagIds], and those alone.
|
||||
final class const StudySessionEventTagsChanged(
|
||||
/// The ids of its tags.
|
||||
final Set<String> tagIds,
|
||||
) extends StudySessionEvent {
|
||||
@override
|
||||
List<Object?> get props => [tagIds];
|
||||
}
|
||||
|
||||
/// The card on screen was edited elsewhere: show it as saved.
|
||||
final class const StudySessionEventCardEdited() extends StudySessionEvent;
|
||||
|
||||
/// Delete the card on screen, and go on with the next.
|
||||
final class const StudySessionEventCardDeleted() extends StudySessionEvent;
|
||||
|
||||
/// Ask the back of cards first, or the front again: a new round so.
|
||||
final class const StudySessionEventBackFirstToggled() extends StudySessionEvent;
|
||||
|
||||
|
|
@ -529,6 +638,12 @@ final class const StudySessionStateShowing({
|
|||
/// The card.
|
||||
required final Card card,
|
||||
|
||||
/// The studyset it is in.
|
||||
required final String studysetId,
|
||||
|
||||
/// Its tags.
|
||||
required final List<Tag> tags,
|
||||
|
||||
/// Whether it asks its back, to recall the front.
|
||||
required final bool asksBack,
|
||||
|
||||
|
|
@ -572,6 +687,8 @@ final class const StudySessionStateShowing({
|
|||
StudySessionStateShowing withAnswerShown(bool shown) =>
|
||||
StudySessionStateShowing(
|
||||
card: card,
|
||||
studysetId: studysetId,
|
||||
tags: tags,
|
||||
asksBack: asksBack,
|
||||
schedule: schedule,
|
||||
shownAt: shownAt,
|
||||
|
|
@ -590,6 +707,8 @@ final class const StudySessionStateShowing({
|
|||
@override
|
||||
List<Object?> get props => [
|
||||
card,
|
||||
studysetId,
|
||||
tags,
|
||||
asksBack,
|
||||
schedule,
|
||||
shownAt,
|
||||
|
|
|
|||
|
|
@ -2,37 +2,67 @@ import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
|||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A number with fewer and more beside it; a null action can't go further.
|
||||
/// A number between a minus and a plus, the width of what holds it, in an
|
||||
/// outlined box: the number bold, [suffix] (" / day") lighter after it. A
|
||||
/// null action can't go further.
|
||||
class const CountStepper({
|
||||
required final String value,
|
||||
required final VoidCallback? onFewer,
|
||||
required final VoidCallback? onMore,
|
||||
|
||||
/// Said after the number, lighter.
|
||||
final String suffix = '',
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Row(
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton.outlined(
|
||||
IconButton(
|
||||
tooltip: l10n.studyFewer,
|
||||
onPressed: onFewer,
|
||||
icon: const Icon(LucideIcons.minus),
|
||||
),
|
||||
SizedBox(
|
||||
width: 96,
|
||||
child: Text(
|
||||
value,
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: value,
|
||||
children: [
|
||||
if (suffix.isNotEmpty)
|
||||
TextSpan(
|
||||
text: suffix,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: colors.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
IconButton.outlined(
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.studyMore,
|
||||
onPressed: onMore,
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/map_of.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_map.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/pdf_page_chip.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/overflowing_tag_chips_row.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
// Material's Card widget would clash with the domain's Card.
|
||||
import 'package:material_ui/material_ui.dart' hide Card;
|
||||
|
|
@ -20,12 +22,12 @@ import 'package:material_ui/material_ui.dart' hide Card;
|
|||
// Cards answered by picking or sorting have Check below them, and once
|
||||
// checked, the ratings in its place.
|
||||
|
||||
/// The surface a card is studied on: a section card with what the card came
|
||||
/// from on top, and [children] below, centred in the height left while they
|
||||
/// fit, scrolling from the top once they don't. A tap on it calls [onTap].
|
||||
/// The surface a card is studied on: a section card with [header] on top,
|
||||
/// and [children] below, centred in the height left while they fit,
|
||||
/// scrolling from the top once they don't. A tap on it calls [onTap].
|
||||
class const StudyCardSurface({
|
||||
/// The card.
|
||||
required final Card card,
|
||||
/// The row above the card's content (see [StudyCardHeader]).
|
||||
required final Widget header,
|
||||
|
||||
/// What the card shows.
|
||||
required final List<Widget> children,
|
||||
|
|
@ -43,13 +45,8 @@ class const StudyCardSurface({
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (card.content.source case final source?) ...[
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: PdfPageChip(key: ValueKey(card.id), source: source),
|
||||
),
|
||||
header,
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
|
|
@ -71,6 +68,92 @@ class const StudyCardSurface({
|
|||
);
|
||||
}
|
||||
|
||||
/// What a card can do while it is studied.
|
||||
enum _CardAction() {
|
||||
tags,
|
||||
edit,
|
||||
delete,
|
||||
}
|
||||
|
||||
/// The row on top of a studied card: the PDF it was made from, its tags on
|
||||
/// one line (or a way to add some), and a menu to tag, edit or delete it.
|
||||
class const StudyCardHeader({
|
||||
/// The card.
|
||||
required final Card card,
|
||||
|
||||
/// Its tags.
|
||||
required final List<Tag> tags,
|
||||
|
||||
/// Opens the tags to choose.
|
||||
required final VoidCallback onTags,
|
||||
|
||||
/// Opens the card to edit.
|
||||
required final VoidCallback onEdit,
|
||||
|
||||
/// Deletes the card, asking first.
|
||||
required final VoidCallback onDelete,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final error = Theme.of(context).colorScheme.error;
|
||||
PopupMenuItem<_CardAction> item(
|
||||
_CardAction action,
|
||||
IconData icon,
|
||||
String label, {
|
||||
Color? color,
|
||||
}) => PopupMenuItem(
|
||||
value: action,
|
||||
child: ListTile(
|
||||
leading: Icon(icon, color: color),
|
||||
title: Text(label, style: TextStyle(color: color)),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
),
|
||||
);
|
||||
return Row(
|
||||
children: [
|
||||
if (card.content.source case final source?) ...[
|
||||
PdfPageChip(key: ValueKey(card.id), source: source),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
if (tags.isEmpty) ...[
|
||||
ActionChip(
|
||||
avatar: const Icon(LucideIcons.tag, size: 18),
|
||||
label: Text(l10n.cardTagsAdd),
|
||||
onPressed: onTags,
|
||||
),
|
||||
const Spacer(),
|
||||
] else
|
||||
Expanded(
|
||||
child: OverflowingTagChipsRow(tags: tags, onPressed: onTags),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
PopupMenuButton<_CardAction>(
|
||||
tooltip: l10n.readerCardOptions,
|
||||
icon: const Icon(LucideIcons.ellipsisVertical),
|
||||
onSelected: (action) => switch (action) {
|
||||
_CardAction.tags => onTags(),
|
||||
_CardAction.edit => onEdit(),
|
||||
_CardAction.delete => onDelete(),
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
item(_CardAction.tags, LucideIcons.tag, l10n.cardTagsAdd),
|
||||
item(_CardAction.edit, LucideIcons.pencil, l10n.cardTileEdit),
|
||||
item(
|
||||
_CardAction.delete,
|
||||
LucideIcons.trash2,
|
||||
l10n.cardTileDelete,
|
||||
color: error,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The answer of a card, once shown: a line with 24 dp on either side, then
|
||||
/// [answer]; until then, "Show answer" 40 dp below the question.
|
||||
List<Widget> _answerPart(
|
||||
|
|
@ -100,6 +183,9 @@ List<Widget> _answerPart(
|
|||
/// [asksBack].
|
||||
class const FrontBackBody({
|
||||
required final Card card,
|
||||
|
||||
/// The row above the card's content.
|
||||
required final Widget header,
|
||||
required final bool asksBack,
|
||||
required final bool answerShown,
|
||||
|
||||
|
|
@ -118,7 +204,7 @@ class const FrontBackBody({
|
|||
? (content.back, content.front)
|
||||
: (content.front, content.back);
|
||||
return StudyCardSurface(
|
||||
card: card,
|
||||
header: header,
|
||||
onTap: onToggle,
|
||||
children: [
|
||||
RichContentView(
|
||||
|
|
@ -146,6 +232,9 @@ class const FrontBackBody({
|
|||
/// the meanings come first.
|
||||
class const VocabularyBody({
|
||||
required final Card card,
|
||||
|
||||
/// The row above the card's content.
|
||||
required final Widget header,
|
||||
required final bool asksBack,
|
||||
required final bool answerShown,
|
||||
|
||||
|
|
@ -164,7 +253,7 @@ class const VocabularyBody({
|
|||
Widget meanings(TextStyle? style) =>
|
||||
_Meanings(meanings: card.content.meanings, style: style);
|
||||
return StudyCardSurface(
|
||||
card: card,
|
||||
header: header,
|
||||
onTap: onToggle,
|
||||
children: [
|
||||
if (asksBack)
|
||||
|
|
@ -299,6 +388,9 @@ class const _CheckOrRate({
|
|||
/// wrong picks red, and the ratings.
|
||||
class const MultipleChoiceBody({
|
||||
required final Card card,
|
||||
|
||||
/// The row above the card's content.
|
||||
required final Widget header,
|
||||
required final bool checked,
|
||||
required final VoidCallback onCheck,
|
||||
|
||||
|
|
@ -329,7 +421,7 @@ class _MultipleChoiceBodyState() extends State<MultipleChoiceBody> {
|
|||
children: [
|
||||
Expanded(
|
||||
child: StudyCardSurface(
|
||||
card: widget.card,
|
||||
header: widget.header,
|
||||
children: [
|
||||
RichContentView(
|
||||
content: widget.card.content.front,
|
||||
|
|
@ -731,6 +823,9 @@ class const _Item({
|
|||
/// right region green and a wrong pick red, said beneath, and the ratings.
|
||||
class const MapBody({
|
||||
required final Card card,
|
||||
|
||||
/// The row above the card's content.
|
||||
required final Widget header,
|
||||
required final bool checked,
|
||||
required final VoidCallback onCheck,
|
||||
|
||||
|
|
@ -762,16 +857,8 @@ class _MapBodyState() extends State<MapBody> {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (content.source case final source?) ...[
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: PdfPageChip(
|
||||
key: ValueKey(widget.card.id),
|
||||
source: source,
|
||||
),
|
||||
),
|
||||
widget.header,
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
RichContentView(
|
||||
content: content.front,
|
||||
textStyle: theme.textTheme.headlineSmall,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,20 @@
|
|||
import 'package:clock/clock.dart';
|
||||
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/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/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_chip.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_session_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/rating_row.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_card_body.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_settings_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/swipe_to_rate.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_picker_sheet.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
|
|
@ -185,6 +191,7 @@ class const _Header({
|
|||
if (await showStudySettingsSheet(
|
||||
context,
|
||||
studysetId: studysetId,
|
||||
inProgress: state is StudySessionStateShowing,
|
||||
)) {
|
||||
bloc.add(const StudySessionEventSettingsChanged());
|
||||
}
|
||||
|
|
@ -212,15 +219,45 @@ class const _Showing(
|
|||
final key = ValueKey((card.id, state.shownAt));
|
||||
void show() => _send(context, const StudySessionEventAnswerShown());
|
||||
void toggle() => _send(context, const StudySessionEventAnswerToggled());
|
||||
final cardHeader = StudyCardHeader(
|
||||
card: card,
|
||||
tags: state.tags,
|
||||
onTags: () => showTagPicker(
|
||||
context,
|
||||
selected: {for (final tag in state.tags) tag.id},
|
||||
onChanged: (tagIds) =>
|
||||
_send(context, StudySessionEventTagsChanged(tagIds)),
|
||||
),
|
||||
onEdit: () async {
|
||||
final bloc = context.read<StudySessionBloc>();
|
||||
await context.push(RoutePaths.card(state.studysetId, card.id));
|
||||
if (!bloc.isClosed) {
|
||||
bloc.add(const StudySessionEventCardEdited());
|
||||
}
|
||||
},
|
||||
onDelete: () async {
|
||||
final bloc = context.read<StudySessionBloc>();
|
||||
if (await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.composerDeleteTitle,
|
||||
message: l10n.composerDeleteSavedMessage,
|
||||
confirmLabel: l10n.composerDelete,
|
||||
)) {
|
||||
bloc.add(const StudySessionEventCardDeleted());
|
||||
}
|
||||
},
|
||||
);
|
||||
void rate(Rating rating) => _send(context, StudySessionEventRated(rating));
|
||||
final ratings = RatingRow(
|
||||
nextDue: state.nextDue,
|
||||
shownAt: state.shownAt,
|
||||
onRated: (rating) => _send(context, StudySessionEventRated(rating)),
|
||||
onRated: rate,
|
||||
);
|
||||
final body = switch (card.content.type) {
|
||||
CardType.multipleChoice when card.content.map != null => MapBody(
|
||||
key: key,
|
||||
card: card,
|
||||
header: cardHeader,
|
||||
checked: state.answerShown,
|
||||
onCheck: show,
|
||||
ratings: ratings,
|
||||
|
|
@ -228,6 +265,7 @@ class const _Showing(
|
|||
CardType.multipleChoice => MultipleChoiceBody(
|
||||
key: key,
|
||||
card: card,
|
||||
header: cardHeader,
|
||||
checked: state.answerShown,
|
||||
onCheck: show,
|
||||
ratings: ratings,
|
||||
|
|
@ -239,13 +277,17 @@ class const _Showing(
|
|||
onCheck: show,
|
||||
ratings: ratings,
|
||||
),
|
||||
CardType.vocabulary => Column(
|
||||
CardType.vocabulary => SwipeToRate(
|
||||
key: key,
|
||||
onRated: rate,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: VocabularyBody(
|
||||
key: key,
|
||||
card: card,
|
||||
header: cardHeader,
|
||||
asksBack: state.asksBack,
|
||||
answerShown: state.answerShown,
|
||||
onToggle: toggle,
|
||||
|
|
@ -255,13 +297,18 @@ class const _Showing(
|
|||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
CardType.frontBack => Column(
|
||||
),
|
||||
CardType.frontBack => SwipeToRate(
|
||||
key: key,
|
||||
onRated: rate,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: FrontBackBody(
|
||||
key: key,
|
||||
card: card,
|
||||
header: cardHeader,
|
||||
asksBack: state.asksBack,
|
||||
answerShown: state.answerShown,
|
||||
onToggle: toggle,
|
||||
|
|
@ -271,6 +318,7 @@ class const _Showing(
|
|||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
};
|
||||
// Cards answered by picking or sorting have the ratings in their own
|
||||
// bodies, once checked; the others always below.
|
||||
|
|
|
|||
|
|
@ -6,14 +6,19 @@ import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart
|
|||
import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_settings_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/count_stepper.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Shows how the studyset [studysetId] is studied, to change it: the tags
|
||||
/// its session narrows to. Completes with true once a change is saved.
|
||||
/// Shows how the studyset [studysetId] is studied, to change it: the study
|
||||
/// options and the tags its session narrows to. Completes with true once a
|
||||
/// change is saved; saving while a round is [inProgress] warns first that
|
||||
/// it starts anew.
|
||||
Future<bool> showStudySettingsSheet(
|
||||
BuildContext context, {
|
||||
required String studysetId,
|
||||
bool inProgress = false,
|
||||
}) async =>
|
||||
await showAdaptiveSheet<bool>(
|
||||
context,
|
||||
|
|
@ -27,13 +32,44 @@ Future<bool> showStudySettingsSheet(
|
|||
person.settings,
|
||||
studysetId,
|
||||
)..add(const StudySettingsEventStarted()),
|
||||
child: const _StudySettings(),
|
||||
child: _StudySettings(inProgress: inProgress),
|
||||
);
|
||||
},
|
||||
) ??
|
||||
false;
|
||||
|
||||
class const _StudySettings() extends StatelessWidget {
|
||||
class const _StudySettings({required final bool inProgress})
|
||||
extends StatelessWidget {
|
||||
/// Saves, once the person agreed to start the round anew if one is under
|
||||
/// way.
|
||||
Future<void> _save(BuildContext context) async {
|
||||
final bloc = context.read<StudySettingsBloc>();
|
||||
if (inProgress) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final apply = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(l10n.studySettingsHeadsUpTitle),
|
||||
content: Text(l10n.studySettingsHeadsUpBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(l10n.commonCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(l10n.studySettingsApplyChanges),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (apply != true) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
bloc.add(const StudySettingsEventSaveRequested());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
|
@ -43,16 +79,17 @@ class const _StudySettings() extends StatelessWidget {
|
|||
current is StudySettingsStateLoaded && current.isDone,
|
||||
listener: (context, state) => Navigator.of(context).pop(true),
|
||||
builder: (context, state) => SheetScaffold(
|
||||
title: l10n.studySettingsTitle,
|
||||
title: l10n.settingsTitle,
|
||||
footer: switch (state) {
|
||||
final StudySettingsStateLoaded loaded => FilledButton(
|
||||
final StudySettingsStateLoaded loaded => SizedBox(
|
||||
height: 48,
|
||||
child: FilledButton(
|
||||
onPressed: loaded.canSave && !loaded.isDone
|
||||
? () => context.read<StudySettingsBloc>().add(
|
||||
const StudySettingsEventSaveRequested(),
|
||||
)
|
||||
? () => _save(context)
|
||||
: null,
|
||||
child: Text(l10n.studySettingsSave),
|
||||
),
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
body: switch (state) {
|
||||
|
|
@ -72,7 +109,7 @@ class const _StudySettings() extends StatelessWidget {
|
|||
Expanded(
|
||||
child: Text(
|
||||
l10n.studySettingsFilterByTags,
|
||||
style: theme.textTheme.labelLarge,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
|
|
@ -85,29 +122,8 @@ class const _StudySettings() extends StatelessWidget {
|
|||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
loaded.tags.isEmpty
|
||||
? l10n.studySettingsNoTags
|
||||
: l10n.studySettingsFilterHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final tag in loaded.tags)
|
||||
SelectableTagChip(
|
||||
tag: tag,
|
||||
selected: loaded.chosen.contains(tag.id),
|
||||
onSelected: (_) => context.read<StudySettingsBloc>().add(
|
||||
StudySettingsEventTagToggled(tag.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_ChosenTags(tags: loaded.tags, chosen: loaded.chosen),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
|
|
@ -137,6 +153,7 @@ class const _Options({required final StudyOptions options})
|
|||
const SizedBox(height: 8),
|
||||
CountStepper(
|
||||
value: '${options.cardsPerRound}',
|
||||
suffix: ' / ${StudyOptions.maxCardsPerRound}',
|
||||
onFewer: options.cardsPerRound > StudyOptions.minCardsPerRound
|
||||
? () => change(
|
||||
options.copyWith(cardsPerRound: options.cardsPerRound - 1),
|
||||
|
|
@ -163,6 +180,9 @@ class const _Options({required final StudyOptions options})
|
|||
value: options.newPerDay == StudyOptions.unlimitedNewPerDay
|
||||
? l10n.studySettingsUnlimited
|
||||
: '${options.newPerDay}',
|
||||
suffix: options.newPerDay == StudyOptions.unlimitedNewPerDay
|
||||
? ''
|
||||
: l10n.studySettingsPerDay,
|
||||
onFewer: options.newPerDay > StudyOptions.unlimitedNewPerDay
|
||||
? () => change(
|
||||
options.copyWith(newPerDay: options.newPerDay - _newStep),
|
||||
|
|
@ -178,12 +198,18 @@ class const _Options({required final StudyOptions options})
|
|||
const SizedBox(height: 20),
|
||||
Text(l10n.studySettingsSortBy, style: heading),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<StudyOrder>(
|
||||
initialValue: options.effectiveOrder,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: DropdownButton<StudyOrder>(
|
||||
value: options.effectiveOrder,
|
||||
isExpanded: true,
|
||||
underline: const SizedBox.shrink(),
|
||||
icon: const Icon(LucideIcons.chevronDown),
|
||||
items: [
|
||||
for (final order in StudyOrder.values)
|
||||
// Newest first is for cramming.
|
||||
|
|
@ -200,8 +226,11 @@ class const _Options({required final StudyOptions options})
|
|||
],
|
||||
onChanged: (order) => change(options.copyWith(order: order)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(l10n.studySettingsLearnMode, style: heading),
|
||||
const SizedBox(height: 4),
|
||||
RadioGroup<LearnMode>(
|
||||
groupValue: options.mode,
|
||||
onChanged: (mode) => change(options.copyWith(mode: mode)),
|
||||
|
|
@ -231,3 +260,96 @@ class const _Options({required final StudyOptions options})
|
|||
|
||||
/// How far the daily limit of new cards moves at a step.
|
||||
const _newStep = 5;
|
||||
|
||||
/// The tags chosen to narrow the session, each to unchoose, and a chip
|
||||
/// opening every tag to choose from: "+3 more", or "Filter by tags" while
|
||||
/// none is chosen.
|
||||
class const _ChosenTags({
|
||||
required final List<Tag> tags,
|
||||
required final Set<String> chosen,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
if (tags.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
l10n.studySettingsNoTags,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
void toggle(String tagId) => context.read<StudySettingsBloc>().add(
|
||||
StudySettingsEventTagToggled(tagId),
|
||||
);
|
||||
final shown = [
|
||||
for (final tag in tags)
|
||||
if (chosen.contains(tag.id)) tag,
|
||||
];
|
||||
final others = tags.length - shown.length;
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final tag in shown)
|
||||
SelectableTagChip(
|
||||
tag: tag,
|
||||
selected: true,
|
||||
onSelected: (_) => toggle(tag.id),
|
||||
),
|
||||
if (others > 0)
|
||||
ActionChip(
|
||||
avatar: const Icon(LucideIcons.tag, size: 18),
|
||||
label: Text(
|
||||
shown.isEmpty
|
||||
? l10n.studySettingsFilterByTags
|
||||
: l10n.studySettingsMoreTags(others),
|
||||
),
|
||||
onPressed: () {
|
||||
final bloc = context.read<StudySettingsBloc>();
|
||||
showAdaptiveSheet<void>(
|
||||
context,
|
||||
builder: (context) =>
|
||||
BlocProvider.value(value: bloc, child: const _AllTags()),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every tag, to choose those that narrow the session; a choice applies at
|
||||
/// once.
|
||||
class const _AllTags() extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => SheetScaffold(
|
||||
title: AppLocalizations.of(context).studySettingsFilterByTags,
|
||||
body: BlocBuilder<StudySettingsBloc, StudySettingsState>(
|
||||
builder: (context, state) => switch (state) {
|
||||
final StudySettingsStateLoaded loaded => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final tag in loaded.tags)
|
||||
SelectableTagChip(
|
||||
tag: tag,
|
||||
selected: loaded.chosen.contains(tag.id),
|
||||
onSelected: (_) => context.read<StudySettingsBloc>().add(
|
||||
StudySettingsEventTagToggled(tag.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_ => const SizedBox.shrink(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ Future<StudyOptionsChoice?> showStudysetStudyOptionsSheet(
|
|||
const _newStep = 5;
|
||||
|
||||
/// How far the daily limit of reviews moves at a step.
|
||||
const _reviewsStep = 50;
|
||||
const _reviewsStep = 25;
|
||||
|
||||
class const _StudyOptionsSheet({
|
||||
required final StudysetStudyOptions? current,
|
||||
|
|
@ -52,18 +52,18 @@ class _StudyOptionsSheetState() extends State<_StudyOptionsSheet> {
|
|||
String limit(int value) => value == StudysetStudyOptions.unlimited
|
||||
? l10n.studySettingsUnlimited
|
||||
: '$value';
|
||||
String perDay(int value) =>
|
||||
value == StudysetStudyOptions.unlimited ? '' : l10n.studySettingsPerDay;
|
||||
final StudysetStudyOptions(:newPerDay, :reviewsPerDay, :order) = _options;
|
||||
return SheetScaffold(
|
||||
title: l10n.studyOptionsTitle,
|
||||
footer: Row(
|
||||
footer: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget.current != null)
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop((options: null)),
|
||||
child: Text(l10n.studyOptionsReset),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: FilledButton(
|
||||
// Saved as they came, the defaults stay the defaults: the
|
||||
// studyset keeps following the person's study settings.
|
||||
onPressed: () => Navigator.of(context).pop((
|
||||
|
|
@ -73,22 +73,32 @@ class _StudyOptionsSheetState() extends State<_StudyOptionsSheet> {
|
|||
)),
|
||||
child: Text(l10n.commonSave),
|
||||
),
|
||||
),
|
||||
if (widget.current != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop((options: null)),
|
||||
child: Text(l10n.studyOptionsReset),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.studyOptionsDescription,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 16),
|
||||
Text(l10n.studySettingsNewPerDay, style: heading),
|
||||
const SizedBox(height: 8),
|
||||
CountStepper(
|
||||
value: limit(newPerDay),
|
||||
suffix: perDay(newPerDay),
|
||||
onFewer: newPerDay > StudysetStudyOptions.unlimited
|
||||
? () => _change(
|
||||
_options.copyWith(newPerDay: newPerDay - _newStep),
|
||||
|
|
@ -102,16 +112,10 @@ class _StudyOptionsSheetState() extends State<_StudyOptionsSheet> {
|
|||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(l10n.studyOptionsReviewsPerDay, style: heading),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.studyOptionsReviewsPerDayHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CountStepper(
|
||||
value: limit(reviewsPerDay),
|
||||
suffix: perDay(reviewsPerDay),
|
||||
onFewer: reviewsPerDay > StudysetStudyOptions.unlimited
|
||||
? () => _change(
|
||||
_options.copyWith(
|
||||
|
|
@ -147,7 +151,6 @@ class _StudyOptionsSheetState() extends State<_StudyOptionsSheet> {
|
|||
onSelectionChanged: (selection) =>
|
||||
_change(_options.copyWith(order: selection.single)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// On phones, [child], a card, can be swiped away to rate it: to the right
|
||||
/// Good, to the left Bad. It follows the finger, tilting slightly, over a
|
||||
/// tint of the rating it is heading for; let go past a quarter of the
|
||||
/// window's width, or flung, it flies off and [onRated] is called, else it
|
||||
/// springs back. Larger windows show [child] as it is.
|
||||
class const SwipeToRate({
|
||||
/// The card.
|
||||
required final Widget child,
|
||||
|
||||
/// Called with the rating once the card has flown off.
|
||||
required final ValueChanged<Rating> onRated,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<SwipeToRate> createState() => _SwipeToRateState();
|
||||
}
|
||||
|
||||
class _SwipeToRateState()
|
||||
extends State<SwipeToRate>
|
||||
with SingleTickerProviderStateMixin {
|
||||
/// How far along the window's width a card has to go to be rated.
|
||||
static const _rateFraction = 0.25;
|
||||
|
||||
/// How fast a fling rates a card, wherever it is let go.
|
||||
static const _flingSpeed = 800.0;
|
||||
|
||||
late final _settling = AnimationController(vsync: this)
|
||||
..addStatusListener(_settled);
|
||||
var _dragX = 0.0;
|
||||
Animation<double>? _settle;
|
||||
Rating? _rating;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_settling.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _settled(AnimationStatus status) {
|
||||
if (status != AnimationStatus.completed || !mounted) {
|
||||
return;
|
||||
}
|
||||
if (_rating case final rating?) {
|
||||
widget.onRated(rating);
|
||||
} else {
|
||||
setState(() {
|
||||
_dragX = 0;
|
||||
_settle = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _dragged(DragUpdateDetails details) {
|
||||
if (_rating == null) {
|
||||
setState(() {
|
||||
_settle = null;
|
||||
_dragX += details.delta.dx;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _let(DragEndDetails details) {
|
||||
if (_rating != null) {
|
||||
return;
|
||||
}
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
final speed = details.velocity.pixelsPerSecond.dx;
|
||||
final far = width * _rateFraction;
|
||||
if (_dragX > far || speed > _flingSpeed) {
|
||||
_fly(to: width * 1.2, rating: Rating.good);
|
||||
} else if (_dragX < -far || speed < -_flingSpeed) {
|
||||
_fly(to: -width * 1.2, rating: Rating.again);
|
||||
} else {
|
||||
_fly(to: 0, rating: null);
|
||||
}
|
||||
}
|
||||
|
||||
void _fly({required double to, required Rating? rating}) {
|
||||
_settling.duration = rating == null
|
||||
? const Duration(milliseconds: 180)
|
||||
: const Duration(milliseconds: 220);
|
||||
setState(() {
|
||||
_rating = rating;
|
||||
_settle = Tween(begin: _dragX, end: to).animate(
|
||||
CurvedAnimation(parent: _settling, curve: Curves.easeOutCubic),
|
||||
);
|
||||
});
|
||||
_settling.forward(from: 0);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = RepaintBoundary(child: widget.child);
|
||||
if (context.windowSize != WindowSize.compact) {
|
||||
return child;
|
||||
}
|
||||
final extras = Theme.of(context).extras;
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragUpdate: _dragged,
|
||||
onHorizontalDragEnd: _let,
|
||||
child: AnimatedBuilder(
|
||||
animation: _settling,
|
||||
builder: (context, child) {
|
||||
final x = _settle?.value ?? _dragX;
|
||||
final progress = (x.abs() / (width * _rateFraction)).clamp(0.0, 1.0);
|
||||
return Stack(
|
||||
children: [
|
||||
if (progress >= 0.02)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: (x > 0 ? extras.fsrsGood : extras.fsrsAgain)
|
||||
.withValues(alpha: 0.15 * progress),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Transform.translate(
|
||||
offset: Offset(x, 0),
|
||||
child: Transform.rotate(
|
||||
angle: x / width * 0.08,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// [tags] as chips on at most [maxLines] lines: as many as fit, then a chip
|
||||
/// saying how many more there are. Every chip calls [onPressed].
|
||||
class const OverflowingTagChipsRow({
|
||||
/// The tags, in the order shown.
|
||||
required final List<Tag> tags,
|
||||
|
||||
/// Called on a tap on any chip.
|
||||
required final VoidCallback onPressed,
|
||||
|
||||
/// The most lines the chips take.
|
||||
final int maxLines = 1,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
/// A chip's width beside its label: its padding and border.
|
||||
static const _chrome = 34.0;
|
||||
|
||||
/// The narrowest a chip is.
|
||||
static const _minWidth = 48.0;
|
||||
|
||||
static const _spacing = 8.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final style =
|
||||
Theme.of(context).textTheme.labelLarge ?? const TextStyle(fontSize: 14);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final widths = [for (final tag in tags) _width(tag.name, style)];
|
||||
final shown = _fitting(widths, constraints.maxWidth, (hidden) {
|
||||
return _width(l10n.tagChipsOverflow(hidden), style);
|
||||
});
|
||||
final hidden = tags.length - shown;
|
||||
return Wrap(
|
||||
spacing: _spacing,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final tag in tags.take(shown))
|
||||
TagChip(tag: tag, onPressed: onPressed),
|
||||
if (hidden > 0)
|
||||
ActionChip(
|
||||
label: Text(l10n.tagChipsOverflow(hidden)),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static double _width(String label, TextStyle style) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(text: label, style: style),
|
||||
textDirection: TextDirection.ltr,
|
||||
maxLines: 1,
|
||||
)..layout();
|
||||
final width = painter.width + _chrome;
|
||||
painter.dispose();
|
||||
return width < _minWidth ? _minWidth : width;
|
||||
}
|
||||
|
||||
/// How many of the chips [widths] wide fit into [maxWidth], with room for
|
||||
/// the chip saying how many more there are, [overflowWidth] wide.
|
||||
int _fitting(
|
||||
List<double> widths,
|
||||
double maxWidth,
|
||||
double Function(int hidden) overflowWidth,
|
||||
) {
|
||||
if (_fits(widths, maxWidth)) {
|
||||
return widths.length;
|
||||
}
|
||||
for (var shown = widths.length - 1; shown >= 0; shown--) {
|
||||
final trial = [
|
||||
...widths.take(shown),
|
||||
overflowWidth(widths.length - shown),
|
||||
];
|
||||
if (_fits(trial, maxWidth)) {
|
||||
return shown;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool _fits(List<double> widths, double maxWidth) {
|
||||
var line = 1;
|
||||
var lineWidth = 0.0;
|
||||
for (final width in widths) {
|
||||
final wider = lineWidth == 0 ? width : lineWidth + _spacing + width;
|
||||
if (wider > maxWidth && lineWidth > 0) {
|
||||
line++;
|
||||
if (line > maxLines) {
|
||||
return false;
|
||||
}
|
||||
lineWidth = width;
|
||||
} else {
|
||||
lineWidth = wider;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -106,6 +106,9 @@ void main() {
|
|||
|
||||
await tester.tap(find.byTooltip('Close session'));
|
||||
await tester.pumpAndSettle();
|
||||
// Leaving a round under way asks first.
|
||||
await tester.tap(find.text('Leave'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(StudysetScreen), findsOneWidget);
|
||||
expect(find.byType(NavigationRail), findsOneWidget);
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Per week'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('More'));
|
||||
await tester.tap(find.byTooltip('Increase'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Save goal'));
|
||||
await tester.pumpAndSettle();
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ void main() {
|
|||
await tester.tap(find.text('Add a profile'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Exams');
|
||||
await tester.tap(find.byTooltip('More').first);
|
||||
await tester.tap(find.byTooltip('Increase').first);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
|
|
|||
|
|
@ -152,6 +152,63 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('The card on screen', () {
|
||||
test('shows its tags and its studyset, and takes new tags', () async {
|
||||
final verb = await tags.create('verb');
|
||||
final id = await cards.create('japanese', card('食べる'));
|
||||
await tags.setTagsOf(id, {verb});
|
||||
|
||||
await withClock(Clock(() => now), () async {
|
||||
final bloc = session();
|
||||
addTearDown(bloc.close);
|
||||
final shown = await showing(bloc, '食べる');
|
||||
expect(shown.tags.map((tag) => tag.name), ['verb']);
|
||||
expect(shown.studysetId, 'japanese');
|
||||
|
||||
bloc.add(const StudySessionEventTagsChanged({}));
|
||||
await until(
|
||||
bloc,
|
||||
(s) => s is StudySessionStateShowing && s.tags.isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
expect((await tags.read()).of(id), isEmpty);
|
||||
});
|
||||
|
||||
test('deleted, leaves the round and the studyset', () async {
|
||||
await cards.create('japanese', card('猫'));
|
||||
await cards.create('japanese', card('犬'));
|
||||
|
||||
await withClock(Clock(() => now), () async {
|
||||
final bloc = session();
|
||||
addTearDown(bloc.close);
|
||||
await showing(bloc, '猫');
|
||||
|
||||
bloc.add(const StudySessionEventCardDeleted());
|
||||
expect((await showing(bloc, '犬')).pills, hasLength(1));
|
||||
});
|
||||
|
||||
expect(
|
||||
(await cards.cardsOf('japanese')).map((each) => each.content.front),
|
||||
[text('犬')],
|
||||
);
|
||||
});
|
||||
|
||||
test('edited elsewhere, shows as it was saved', () async {
|
||||
final id = await cards.create('japanese', card('猫'));
|
||||
|
||||
await withClock(Clock(() => now), () async {
|
||||
final bloc = session();
|
||||
addTearDown(bloc.close);
|
||||
final shown = await showing(bloc, '猫');
|
||||
|
||||
await cards.saveEdit(id, card('ねこ'), basedOn: shown.card.heads);
|
||||
bloc.add(const StudySessionEventCardEdited());
|
||||
await showing(bloc, 'ねこ');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('a round holds at most the cards per round', () async {
|
||||
for (var i = 0; i < 8; i++) {
|
||||
await cards.create('japanese', card('card $i'));
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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/geo_map/data/geo_map_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_map.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_card_body.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
|
|
@ -59,8 +60,12 @@ void main() {
|
|||
|
||||
/// Studies the studyset `japanese`, with the cards the test gave [cards]:
|
||||
/// the app reads them from the same [gateway].
|
||||
Future<void> study(WidgetTester tester, {GeoMapRepository? maps}) async {
|
||||
tester.view.physicalSize = const Size(1200, 1600);
|
||||
Future<void> study(
|
||||
WidgetTester tester, {
|
||||
GeoMapRepository? maps,
|
||||
double width = 1200,
|
||||
}) async {
|
||||
tester.view.physicalSize = Size(width, 1600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(
|
||||
|
|
@ -127,6 +132,38 @@ void main() {
|
|||
expect(find.textContaining('cat', findRichText: true), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('on a phone, a card swiped right is rated Good, one let go '
|
||||
'early springs back', (tester) async {
|
||||
await cards.create(
|
||||
'japanese',
|
||||
CardContent(front: paragraph('猫'), back: paragraph('cat')),
|
||||
);
|
||||
await study(tester, width: 400);
|
||||
Rating? rated() => tester
|
||||
.widget<PillProgress>(find.byType(PillProgress))
|
||||
.pills
|
||||
.single
|
||||
.rating;
|
||||
// Slowly, so that neither is a fling.
|
||||
const slowly = Duration(milliseconds: 500);
|
||||
|
||||
await tester.timedDrag(
|
||||
find.byType(StudyCardSurface),
|
||||
const Offset(40, 0),
|
||||
slowly,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(rated(), isNull);
|
||||
|
||||
await tester.timedDrag(
|
||||
find.byType(StudyCardSurface),
|
||||
const Offset(200, 0),
|
||||
slowly,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(rated(), Rating.good);
|
||||
});
|
||||
|
||||
testWidgets('leaving in the middle of a round asks first', (tester) async {
|
||||
await cards.create(
|
||||
'japanese',
|
||||
|
|
@ -143,6 +180,50 @@ void main() {
|
|||
expect(find.textContaining('猫', findRichText: true), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('above the card its tags, or a way to add some', (tester) async {
|
||||
final verb = await tags.create('verb');
|
||||
final id = await cards.create(
|
||||
'japanese',
|
||||
CardContent(front: paragraph('食べる'), back: paragraph('to eat')),
|
||||
);
|
||||
await tags.setTagsOf(id, {verb});
|
||||
await cards.create(
|
||||
'japanese',
|
||||
CardContent(front: paragraph('猫'), back: paragraph('cat')),
|
||||
);
|
||||
await study(tester);
|
||||
|
||||
expect(find.widgetWithText(ActionChip, 'verb'), findsOneWidget);
|
||||
expect(find.text('Add tags'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Perfect'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.widgetWithText(ActionChip, 'Add tags'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('deleting a card from its menu asks, then goes on with the '
|
||||
'next', (tester) async {
|
||||
await cards.create(
|
||||
'japanese',
|
||||
CardContent(front: paragraph('猫'), back: paragraph('cat')),
|
||||
);
|
||||
await cards.create(
|
||||
'japanese',
|
||||
CardContent(front: paragraph('犬'), back: paragraph('dog')),
|
||||
);
|
||||
await study(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Card options'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('犬', findRichText: true), findsOneWidget);
|
||||
expect(await cards.cardsOf('japanese'), hasLength(1));
|
||||
});
|
||||
|
||||
testWidgets('says when there is nothing to study', (tester) async {
|
||||
await study(tester);
|
||||
|
||||
|
|
@ -289,12 +370,23 @@ void main() {
|
|||
|
||||
await tester.tap(find.byTooltip('Session settings'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.ensureVisible(find.widgetWithText(FilterChip, 'verb'));
|
||||
final filter = find.widgetWithText(ActionChip, 'Filter by tags');
|
||||
await tester.ensureVisible(filter);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(filter);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilterChip, 'verb'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('Close').last);
|
||||
await tester.pumpAndSettle();
|
||||
// Chosen, it shows in the settings, to unchoose.
|
||||
expect(find.widgetWithText(FilterChip, 'verb'), findsOneWidget);
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
// A round is under way: saving starts it anew.
|
||||
expect(find.text('Heads up!'), findsOneWidget);
|
||||
await tester.tap(find.text('Apply changes'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(pills(), 1);
|
||||
expect(find.textContaining('食べる', findRichText: true), findsOneWidget);
|
||||
|
|
|
|||
|
|
@ -402,13 +402,17 @@ void main() {
|
|||
await showLibrary(tester);
|
||||
|
||||
await choose(tester, 'Japanese', 'Study options');
|
||||
expect(find.text('20'), findsOneWidget, reason: 'new cards a day');
|
||||
expect(
|
||||
find.text('20 / day', findRichText: true),
|
||||
findsOneWidget,
|
||||
reason: 'new cards a day',
|
||||
);
|
||||
expect(find.text('Unlimited'), findsOneWidget, reason: 'reviews a day');
|
||||
expect(find.text('Reset to defaults'), findsNothing);
|
||||
|
||||
await tester.tap(find.byTooltip('Fewer').first);
|
||||
await tester.tap(find.byTooltip('Decrease').first);
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('More').last);
|
||||
await tester.tap(find.byTooltip('Increase').last);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Mixed'));
|
||||
await tester.pump();
|
||||
|
|
@ -419,7 +423,7 @@ void main() {
|
|||
written('japanese'),
|
||||
const StudysetStudyOptions(
|
||||
newPerDay: 15,
|
||||
reviewsPerDay: 50,
|
||||
reviewsPerDay: 25,
|
||||
order: NewCardOrder.mixed,
|
||||
),
|
||||
);
|
||||
|
|
@ -450,7 +454,7 @@ void main() {
|
|||
await showLibrary(tester);
|
||||
|
||||
await choose(tester, 'Japanese', 'Study options');
|
||||
expect(find.text('5'), findsOneWidget);
|
||||
expect(find.text('5 / day', findRichText: true), findsOneWidget);
|
||||
await tester.tap(find.text('Reset to defaults'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue