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:
Tizian 「ティツィアーン」 2026-10-07 18:13:32 +02:00
commit b0aaecf115
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
20 changed files with 1109 additions and 220 deletions

View file

@ -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.

View file

@ -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?",

View file

@ -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?",

View file

@ -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.

View file

@ -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';

View file

@ -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';

View file

@ -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,

View file

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

View file

@ -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,

View file

@ -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.

View file

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

View file

@ -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),
],
),
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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