feat(app): the library's options move, import into and set how a studyset is studied
A studyset's options in the library now offer, as on its own screen, Edit, Move to…, Study options, Import from Anki, Archive and Delete. Moving goes through the same sheet and says where the studyset went; importing reads an Anki deck into it as Add material does. Study options say how a studyset is studied, on every device: how many new cards it starts a day (by default as the person's study settings say), how many reviews it brings a day (no limit by default; cards in their learning steps come anyway), and whether reviews or new cards come first, or the new ones spread evenly among the reviews. They live in the studyset's payload, schema 3; a studyset without them is still written in schema 2, so that older apps, which skip a payload in a newer schema whole, keep seeing every studyset nobody gave options. Editing a studyset keeps its options, and Reset to defaults takes them away. The study queue takes each studyset's limits for its own cards and the order of the studyset studied; a card's schedule now knows when it was last reviewed, to count the reviews of the day. Sessions, the round's end and the library's badges all count the same way, the badges now with the person's own number of new cards a day too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
3ee9bce0c5
commit
d42afb078a
29 changed files with 1095 additions and 137 deletions
|
|
@ -428,7 +428,16 @@
|
|||
"studyFilteredBy": "{count, plural, =1{Eingegrenzt durch 1 Tag-Filter} other{Eingegrenzt durch {count} Tag-Filter}}",
|
||||
"studySettingsCardsPerRound": "Karten pro Runde",
|
||||
"studySettingsNewPerDay": "Neue Karten pro Tag",
|
||||
"studySettingsNewPerDayHint": "Wie viele noch nie gelernte Karten jedes Lernset pro Tag neu beginnt. Fällige Wiederholungen werden nie begrenzt.",
|
||||
"studySettingsNewPerDayHint": "Wie viele noch nie gelernte Karten jedes Lernset pro Tag neu beginnt, sofern seine eigenen Lernoptionen nichts anderes sagen.",
|
||||
"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",
|
||||
"studyOptionsNewFirst": "Neue zuerst",
|
||||
"studyOptionsReset": "Standard wiederherstellen",
|
||||
"studySettingsUnlimited": "Unbegrenzt",
|
||||
"studySettingsSortBy": "Sortieren nach",
|
||||
"studySettingsLearnMode": "Lernmodus",
|
||||
|
|
|
|||
|
|
@ -478,7 +478,16 @@
|
|||
"@studyFilteredBy": {"placeholders": {"count": {"type": "int"}}},
|
||||
"studySettingsCardsPerRound": "Cards per round",
|
||||
"studySettingsNewPerDay": "New cards per day",
|
||||
"studySettingsNewPerDayHint": "How many never-studied cards each studyset starts a day. Due reviews are never limited.",
|
||||
"studySettingsNewPerDayHint": "How many never-studied cards each studyset starts a day, unless its own study options say otherwise.",
|
||||
"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",
|
||||
"studyOptionsNewFirst": "New first",
|
||||
"studyOptionsReset": "Reset to defaults",
|
||||
"studySettingsUnlimited": "Unlimited",
|
||||
"studySettingsSortBy": "Sort by",
|
||||
"studySettingsLearnMode": "Learn mode",
|
||||
|
|
|
|||
|
|
@ -2587,9 +2587,63 @@ abstract class AppLocalizations {
|
|||
/// No description provided for @studySettingsNewPerDayHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'How many never-studied cards each studyset starts a day. Due reviews are never limited.'**
|
||||
/// **'How many never-studied cards each studyset starts a day, unless its own study options say otherwise.'**
|
||||
String get studySettingsNewPerDayHint;
|
||||
|
||||
/// No description provided for @studyOptionsTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Study options'**
|
||||
String get studyOptionsTitle;
|
||||
|
||||
/// No description provided for @studyOptionsDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'How many cards this studyset brings a day, and which come first, on every device. The studysets inside it keep their own.'**
|
||||
String get studyOptionsDescription;
|
||||
|
||||
/// No description provided for @studyOptionsReviewsPerDay.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'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:
|
||||
/// **'Order'**
|
||||
String get studyOptionsOrder;
|
||||
|
||||
/// No description provided for @studyOptionsReviewsFirst.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reviews first'**
|
||||
String get studyOptionsReviewsFirst;
|
||||
|
||||
/// No description provided for @studyOptionsMixed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Mixed'**
|
||||
String get studyOptionsMixed;
|
||||
|
||||
/// No description provided for @studyOptionsNewFirst.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New first'**
|
||||
String get studyOptionsNewFirst;
|
||||
|
||||
/// No description provided for @studyOptionsReset.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reset to defaults'**
|
||||
String get studyOptionsReset;
|
||||
|
||||
/// No description provided for @studySettingsUnlimited.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -1526,7 +1526,36 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get studySettingsNewPerDayHint =>
|
||||
'Wie viele noch nie gelernte Karten jedes Lernset pro Tag neu beginnt. Fällige Wiederholungen werden nie begrenzt.';
|
||||
'Wie viele noch nie gelernte Karten jedes Lernset pro Tag neu beginnt, sofern seine eigenen Lernoptionen nichts anderes sagen.';
|
||||
|
||||
@override
|
||||
String get studyOptionsTitle => 'Lernoptionen';
|
||||
|
||||
@override
|
||||
String get studyOptionsDescription =>
|
||||
'Wie viele Karten dieses Lernset pro Tag bringt und welche zuerst kommen, auf all deinen Geräten. Die Lernsets darin behalten ihre eigenen.';
|
||||
|
||||
@override
|
||||
String get studyOptionsReviewsPerDay => 'Wiederholungen pro Tag';
|
||||
|
||||
@override
|
||||
String get studyOptionsReviewsPerDayHint =>
|
||||
'Karten in ihren Lernschritten kommen trotzdem.';
|
||||
|
||||
@override
|
||||
String get studyOptionsOrder => 'Reihenfolge';
|
||||
|
||||
@override
|
||||
String get studyOptionsReviewsFirst => 'Wiederholungen zuerst';
|
||||
|
||||
@override
|
||||
String get studyOptionsMixed => 'Gemischt';
|
||||
|
||||
@override
|
||||
String get studyOptionsNewFirst => 'Neue zuerst';
|
||||
|
||||
@override
|
||||
String get studyOptionsReset => 'Standard wiederherstellen';
|
||||
|
||||
@override
|
||||
String get studySettingsUnlimited => 'Unbegrenzt';
|
||||
|
|
|
|||
|
|
@ -1514,7 +1514,36 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get studySettingsNewPerDayHint =>
|
||||
'How many never-studied cards each studyset starts a day. Due reviews are never limited.';
|
||||
'How many never-studied cards each studyset starts a day, unless its own study options say otherwise.';
|
||||
|
||||
@override
|
||||
String get studyOptionsTitle => 'Study options';
|
||||
|
||||
@override
|
||||
String get studyOptionsDescription =>
|
||||
'How many cards this studyset brings a day, and which come first, on every device. The studysets inside it keep their own.';
|
||||
|
||||
@override
|
||||
String get studyOptionsReviewsPerDay => 'Reviews per day';
|
||||
|
||||
@override
|
||||
String get studyOptionsReviewsPerDayHint =>
|
||||
'Cards in their learning steps come anyway.';
|
||||
|
||||
@override
|
||||
String get studyOptionsOrder => 'Order';
|
||||
|
||||
@override
|
||||
String get studyOptionsReviewsFirst => 'Reviews first';
|
||||
|
||||
@override
|
||||
String get studyOptionsMixed => 'Mixed';
|
||||
|
||||
@override
|
||||
String get studyOptionsNewFirst => 'New first';
|
||||
|
||||
@override
|
||||
String get studyOptionsReset => 'Reset to defaults';
|
||||
|
||||
@override
|
||||
String get studySettingsUnlimited => 'Unlimited';
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_focus.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
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';
|
||||
|
|
@ -80,6 +81,12 @@ class StudySessionBloc(
|
|||
/// The cards crammed in earlier rounds of this session.
|
||||
final _crammed = <String>{};
|
||||
|
||||
// Each studyset's cards in the round under way, in tree order, with each
|
||||
// one's study options, and the order the studied studyset asks for.
|
||||
var _groupIds = <List<String>>[];
|
||||
var _groupOptions = <StudysetStudyOptions>[];
|
||||
var _order = NewCardOrder.reviewsFirst;
|
||||
|
||||
Future<void> _startRound(Emitter<StudySessionState> emit) async {
|
||||
emit(const StudySessionStateLoading());
|
||||
_options = (await _settings.read()).study;
|
||||
|
|
@ -111,17 +118,10 @@ class StudySessionBloc(
|
|||
return;
|
||||
}
|
||||
final schedules = await _schedules([..._cardsById.keys]);
|
||||
final queue = studyQueue(
|
||||
groups: [
|
||||
for (final group in groups) [for (final card in group) card.id],
|
||||
],
|
||||
schedules: schedules,
|
||||
now: now,
|
||||
startOfToday: startOfLocalDay(now),
|
||||
newPerDay: _options.newPerDay == StudyOptions.unlimitedNewPerDay
|
||||
? 1 << 30
|
||||
: _options.newPerDay,
|
||||
);
|
||||
_groupIds = [
|
||||
for (final group in groups) [for (final card in group) card.id],
|
||||
];
|
||||
final queue = _queue(schedules, now);
|
||||
_round = _ordered(queue).take(_options.cardsPerRound).toList();
|
||||
if (_round.isEmpty) {
|
||||
final upcoming = [
|
||||
|
|
@ -133,14 +133,35 @@ class StudySessionBloc(
|
|||
await _showNext(emit, now);
|
||||
}
|
||||
|
||||
/// The study queue of the round's studysets with their [schedules] at
|
||||
/// [now].
|
||||
List<String> _queue(Map<String, CardSchedule> schedules, DateTime now) =>
|
||||
studyQueue(
|
||||
groups: _groupIds,
|
||||
options: _groupOptions,
|
||||
order: _order,
|
||||
schedules: schedules,
|
||||
now: now,
|
||||
startOfToday: startOfLocalDay(now),
|
||||
);
|
||||
|
||||
/// The cards of every studyset studied, each studyset's in order, with
|
||||
/// the tags chosen for the studyset applied: by the focus, if any, else
|
||||
/// by the settings.
|
||||
/// by the settings. Takes each one's study options along: its own, else
|
||||
/// the settings' new cards a day.
|
||||
Future<List<List<Card>>> _groups() async {
|
||||
final tagged = await _cardsWithChosenTags();
|
||||
final all = await _studysets.all();
|
||||
final studied = _focus?.topics == false
|
||||
? [_studysetId]
|
||||
: studiedIn(_studysetId, await _studysets.all());
|
||||
: studiedIn(_studysetId, all);
|
||||
StudysetStudyOptions? optionsOf(String id) =>
|
||||
all.where((each) => each.id == id).firstOrNull?.content.studyOptions;
|
||||
_groupOptions = [
|
||||
for (final id in studied)
|
||||
optionsOf(id) ?? StudysetStudyOptions(newPerDay: _options.newPerDay),
|
||||
];
|
||||
_order = optionsOf(_studysetId)?.order ?? NewCardOrder.reviewsFirst;
|
||||
return [
|
||||
for (final studysetId in studied)
|
||||
[
|
||||
|
|
@ -288,14 +309,7 @@ class StudySessionBloc(
|
|||
more = _cardsById.keys.any((id) => !_crammed.contains(id));
|
||||
} else {
|
||||
final now = clock.now().toUtc();
|
||||
final ids = [..._cardsById.keys];
|
||||
final schedules = await _schedules(ids);
|
||||
more = studyQueue(
|
||||
groups: [ids],
|
||||
schedules: schedules,
|
||||
now: now,
|
||||
startOfToday: startOfLocalDay(now),
|
||||
).isNotEmpty;
|
||||
more = _queue(await _schedules([..._cardsById.keys]), now).isNotEmpty;
|
||||
}
|
||||
return StudySessionStateRoundDone(tally: _tally(), more: more);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ final class const CardSchedule({
|
|||
/// When the card was first reviewed; null for a card never reviewed.
|
||||
final DateTime? firstReviewedAt,
|
||||
|
||||
/// When the card was last reviewed; null for a card never reviewed.
|
||||
final DateTime? lastReviewedAt,
|
||||
|
||||
/// Whether the card is in its learning or relearning steps.
|
||||
final bool isLearning = false,
|
||||
|
||||
|
|
@ -54,6 +57,7 @@ final class const CardSchedule({
|
|||
reviewCount,
|
||||
due,
|
||||
firstReviewedAt,
|
||||
lastReviewedAt,
|
||||
isLearning,
|
||||
stability,
|
||||
];
|
||||
|
|
@ -85,6 +89,7 @@ CardSchedule scheduleOf(String cardId, Iterable<Review> reviews) {
|
|||
reviewCount: ordered.length,
|
||||
due: _fuzzedDue(cardId, ordered.length, state),
|
||||
firstReviewedAt: ordered.first.reviewedAt.toUtc(),
|
||||
lastReviewedAt: ordered.last.reviewedAt.toUtc(),
|
||||
isLearning: state.state != fsrs.State.review,
|
||||
stability: state.stability,
|
||||
state: state,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
|
||||
/// Where the studying of some cards stands, as the library shows it, with
|
||||
/// how many notes and learning paths there are beside them.
|
||||
|
|
@ -45,7 +46,8 @@ final class const StudyProgress({
|
|||
}
|
||||
|
||||
/// The progress of the cards in [groups] (each studyset's, in tree order, as
|
||||
/// [studyQueue] takes them) with their [schedules] at [now], in the local day
|
||||
/// [studyQueue] takes them, with their [options]) with their [schedules] at
|
||||
/// [now], in the local day
|
||||
/// that began at [startOfToday]: what a session started now would study, and
|
||||
/// how much is mature.
|
||||
StudyProgress studyProgress({
|
||||
|
|
@ -53,10 +55,12 @@ StudyProgress studyProgress({
|
|||
required Map<String, CardSchedule> schedules,
|
||||
required DateTime now,
|
||||
required DateTime startOfToday,
|
||||
List<StudysetStudyOptions> options = const [],
|
||||
}) {
|
||||
final cards = [for (final group in groups) ...group];
|
||||
final queue = studyQueue(
|
||||
groups: groups,
|
||||
options: options,
|
||||
schedules: schedules,
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
|
|
|
|||
|
|
@ -1,42 +1,78 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
|
||||
/// The cards to study at [now], in order (decided 2026-09-30; over a studyset
|
||||
/// with what is inside it, 2026-10-01).
|
||||
/// with what is inside it, 2026-10-01; with each studyset's study options,
|
||||
/// 2026-10-07).
|
||||
///
|
||||
/// [groups] holds the cards of every studyset studied, in tree order, each in
|
||||
/// its own order. First the cards due for review or in their learning steps,
|
||||
/// earliest first, from every studyset; then each studyset's new cards, in
|
||||
/// tree order, at most [newPerDay] started per studyset and local day, the day
|
||||
/// that began at [startOfToday]. When nothing else is due, a learning card due
|
||||
/// within [learnAhead] comes early.
|
||||
/// its own order, and [options] the study options of each, in the same order;
|
||||
/// a studyset without starts [newPerDay] new cards and reviews all that is
|
||||
/// due. The cards due for review or in their learning steps come earliest
|
||||
/// first from every studyset, at most each studyset's reviews a day (cards in
|
||||
/// their learning steps come anyway); the new cards come in tree order, at
|
||||
/// most each studyset's new cards a day. A day is the local one that began at
|
||||
/// [startOfToday], and what was reviewed or started in it counts. [order]
|
||||
/// (the studied studyset's) says whether the due cards or the new ones come
|
||||
/// first, or the new ones spread among the due. When nothing else is due, a
|
||||
/// learning card due within [learnAhead] comes early.
|
||||
List<String> studyQueue({
|
||||
required List<List<String>> groups,
|
||||
required Map<String, CardSchedule> schedules,
|
||||
required DateTime now,
|
||||
required DateTime startOfToday,
|
||||
List<StudysetStudyOptions> options = const [],
|
||||
int newPerDay = 20,
|
||||
NewCardOrder order = NewCardOrder.reviewsFirst,
|
||||
Duration learnAhead = const Duration(minutes: 20),
|
||||
}) {
|
||||
DateTime? dueOf(String id) => schedules[id]?.due;
|
||||
int byDue(String a, String b) => dueOf(a)!.compareTo(dueOf(b)!);
|
||||
bool startedToday(String id) =>
|
||||
schedules[id]?.firstReviewedAt?.isBefore(startOfToday) == false;
|
||||
bool today(DateTime? at) => at?.isBefore(startOfToday) == false;
|
||||
bool startedToday(String id) => today(schedules[id]?.firstReviewedAt);
|
||||
// A review today of a card started before today.
|
||||
bool reviewedToday(String id) =>
|
||||
today(schedules[id]?.lastReviewedAt) && !startedToday(id);
|
||||
int left(int limit, int used) => limit == StudysetStudyOptions.unlimited
|
||||
? 1 << 30
|
||||
: math.max(0, limit - used);
|
||||
final cards = [for (final group in groups) ...group];
|
||||
|
||||
final due = [
|
||||
for (final id in cards)
|
||||
if (dueOf(id) case final due? when !due.isAfter(now)) id,
|
||||
]..sort(byDue);
|
||||
final fresh = [
|
||||
for (final group in groups)
|
||||
...[
|
||||
final due = <String>[];
|
||||
final fresh = <String>[];
|
||||
for (final (index, group) in groups.indexed) {
|
||||
final limits = index < options.length
|
||||
? options[index]
|
||||
: StudysetStudyOptions(newPerDay: newPerDay);
|
||||
final groupDue = [
|
||||
for (final id in group)
|
||||
if (dueOf(id) case final due? when !due.isAfter(now)) id,
|
||||
]..sort(byDue);
|
||||
final learning = groupDue.where((id) => schedules[id]!.isLearning);
|
||||
due
|
||||
..addAll(learning)
|
||||
..addAll(
|
||||
groupDue
|
||||
.where((id) => !schedules[id]!.isLearning)
|
||||
.take(
|
||||
left(limits.reviewsPerDay, group.where(reviewedToday).length),
|
||||
),
|
||||
);
|
||||
fresh.addAll(
|
||||
[
|
||||
for (final id in group)
|
||||
if (schedules[id]?.isNew ?? true) id,
|
||||
].take(math.max(0, newPerDay - group.where(startedToday).length)),
|
||||
];
|
||||
final queue = [...due, ...fresh];
|
||||
].take(left(limits.newPerDay, group.where(startedToday).length)),
|
||||
);
|
||||
}
|
||||
due.sort(byDue);
|
||||
final queue = switch (order) {
|
||||
NewCardOrder.reviewsFirst => [...due, ...fresh],
|
||||
NewCardOrder.newFirst => [...fresh, ...due],
|
||||
NewCardOrder.mixed => _spread(fresh, among: due),
|
||||
};
|
||||
if (queue.isNotEmpty) {
|
||||
return queue;
|
||||
}
|
||||
|
|
@ -50,6 +86,22 @@ List<String> studyQueue({
|
|||
return soon.take(1).toList();
|
||||
}
|
||||
|
||||
/// [these] spread evenly [among] the others, each list in its order: four
|
||||
/// cards and one new one give two cards, the new one, two cards.
|
||||
List<String> _spread(List<String> these, {required List<String> among}) {
|
||||
final spread = <String>[];
|
||||
var next = 0;
|
||||
for (final (index, id) in these.indexed) {
|
||||
// An even share of the others before each one of these.
|
||||
final until = ((index + 1) * among.length / (these.length + 1)).round();
|
||||
while (next < until) {
|
||||
spread.add(among[next++]);
|
||||
}
|
||||
spread.add(id);
|
||||
}
|
||||
return spread..addAll(among.skip(next));
|
||||
}
|
||||
|
||||
/// The start of the learner's local calendar day at [now], in UTC: a day's
|
||||
/// limits count from the local midnight, not from UTC's (AGENTS.md).
|
||||
DateTime startOfLocalDay(DateTime now) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Whether a session over a studyset brings the cards due for review or the
|
||||
/// new ones first.
|
||||
enum NewCardOrder() {
|
||||
/// The cards due first, then the new ones.
|
||||
reviewsFirst,
|
||||
|
||||
/// The new cards spread evenly among the cards due.
|
||||
mixed,
|
||||
|
||||
/// The new cards first, then the cards due.
|
||||
newFirst,
|
||||
}
|
||||
|
||||
/// How a studyset is studied, the same on every device (in the studyset's
|
||||
/// synced content; decided 2026-10-07): how many new cards it starts and how
|
||||
/// many it reviews a local day, and which come first. A studyset without
|
||||
/// options of its own starts as many new cards as the person's study
|
||||
/// settings say, reviews all that is due, and brings the reviews first.
|
||||
final class const StudysetStudyOptions({
|
||||
/// How many never-studied cards the studyset starts a day; [unlimited]
|
||||
/// for no limit.
|
||||
final int newPerDay = 20,
|
||||
|
||||
/// How many cards due for review it brings a day; [unlimited] for no
|
||||
/// limit. Cards in their learning steps come anyway.
|
||||
final int reviewsPerDay = unlimited,
|
||||
|
||||
/// Whether the reviews or the new cards come first.
|
||||
final NewCardOrder order = NewCardOrder.reviewsFirst,
|
||||
}) with Equatable {
|
||||
/// A limit for no limit.
|
||||
static const unlimited = 0;
|
||||
|
||||
/// The highest daily limit of new cards.
|
||||
static const maxNewPerDay = 100;
|
||||
|
||||
/// The highest daily limit of reviews.
|
||||
static const maxReviewsPerDay = 1000;
|
||||
|
||||
/// These options with some replaced, the limits kept in their ranges.
|
||||
StudysetStudyOptions copyWith({
|
||||
int? newPerDay,
|
||||
int? reviewsPerDay,
|
||||
NewCardOrder? order,
|
||||
}) => StudysetStudyOptions(
|
||||
newPerDay: (newPerDay ?? this.newPerDay).clamp(unlimited, maxNewPerDay),
|
||||
reviewsPerDay: (reviewsPerDay ?? this.reviewsPerDay).clamp(
|
||||
unlimited,
|
||||
maxReviewsPerDay,
|
||||
),
|
||||
order: order ?? this.order,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [newPerDay, reviewsPerDay, order];
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
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.
|
||||
class const CountStepper({
|
||||
required final String value,
|
||||
required final VoidCallback? onFewer,
|
||||
required final VoidCallback? onMore,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
IconButton.outlined(
|
||||
tooltip: l10n.studyFewer,
|
||||
onPressed: onFewer,
|
||||
icon: const Icon(LucideIcons.minus),
|
||||
),
|
||||
SizedBox(
|
||||
width: 96,
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
IconButton.outlined(
|
||||
tooltip: l10n.studyMore,
|
||||
onPressed: onMore,
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,8 +5,8 @@ import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||
import 'package:halkyon_learn_flutter/src/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/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
|
||||
|
|
@ -135,7 +135,7 @@ class const _Options({required final StudyOptions options})
|
|||
children: [
|
||||
Text(l10n.studySettingsCardsPerRound, style: heading),
|
||||
const SizedBox(height: 8),
|
||||
_Stepper(
|
||||
CountStepper(
|
||||
value: '${options.cardsPerRound}',
|
||||
onFewer: options.cardsPerRound > StudyOptions.minCardsPerRound
|
||||
? () => change(
|
||||
|
|
@ -159,7 +159,7 @@ class const _Options({required final StudyOptions options})
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_Stepper(
|
||||
CountStepper(
|
||||
value: options.newPerDay == StudyOptions.unlimitedNewPerDay
|
||||
? l10n.studySettingsUnlimited
|
||||
: '${options.newPerDay}',
|
||||
|
|
@ -231,37 +231,3 @@ class const _Options({required final StudyOptions options})
|
|||
|
||||
/// How far the daily limit of new cards moves at a step.
|
||||
const _newStep = 5;
|
||||
|
||||
/// A number with fewer and more beside it; a null action can't go further.
|
||||
class const _Stepper({
|
||||
required final String value,
|
||||
required final VoidCallback? onFewer,
|
||||
required final VoidCallback? onMore,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
IconButton.outlined(
|
||||
tooltip: l10n.studyFewer,
|
||||
onPressed: onFewer,
|
||||
icon: const Icon(LucideIcons.minus),
|
||||
),
|
||||
SizedBox(
|
||||
width: 96,
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
IconButton.outlined(
|
||||
tooltip: l10n.studyMore,
|
||||
onPressed: onMore,
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/count_stepper.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// What the study options sheet ended with: the options to keep, or null to
|
||||
/// go back to the defaults.
|
||||
typedef StudyOptionsChoice = ({StudysetStudyOptions? options});
|
||||
|
||||
/// Shows a studyset's study options to change them: its [current] ones, or
|
||||
/// the defaults (starting [defaultNewPerDay] new cards a day, as the person's
|
||||
/// study settings say) when it has none. Completes with what the person
|
||||
/// saved or reset, or with null when they close the sheet.
|
||||
Future<StudyOptionsChoice?> showStudysetStudyOptionsSheet(
|
||||
BuildContext context, {
|
||||
required StudysetStudyOptions? current,
|
||||
required int defaultNewPerDay,
|
||||
}) => showAdaptiveSheet<StudyOptionsChoice>(
|
||||
context,
|
||||
builder: (context) => _StudyOptionsSheet(
|
||||
current: current,
|
||||
defaults: StudysetStudyOptions(newPerDay: defaultNewPerDay),
|
||||
),
|
||||
);
|
||||
|
||||
/// How far the daily limit of new cards moves at a step.
|
||||
const _newStep = 5;
|
||||
|
||||
/// How far the daily limit of reviews moves at a step.
|
||||
const _reviewsStep = 50;
|
||||
|
||||
class const _StudyOptionsSheet({
|
||||
required final StudysetStudyOptions? current,
|
||||
required final StudysetStudyOptions defaults,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_StudyOptionsSheet> createState() => _StudyOptionsSheetState();
|
||||
}
|
||||
|
||||
class _StudyOptionsSheetState() extends State<_StudyOptionsSheet> {
|
||||
late var _options = widget.current ?? widget.defaults;
|
||||
|
||||
void _change(StudysetStudyOptions options) =>
|
||||
setState(() => _options = options);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final heading = theme.textTheme.titleSmall;
|
||||
String limit(int value) => value == StudysetStudyOptions.unlimited
|
||||
? l10n.studySettingsUnlimited
|
||||
: '$value';
|
||||
final StudysetStudyOptions(:newPerDay, :reviewsPerDay, :order) = _options;
|
||||
return SheetScaffold(
|
||||
title: l10n.studyOptionsTitle,
|
||||
footer: Row(
|
||||
children: [
|
||||
if (widget.current != null)
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop((options: null)),
|
||||
child: Text(l10n.studyOptionsReset),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop((options: _options)),
|
||||
child: Text(l10n.commonSave),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
l10n.studyOptionsDescription,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(l10n.studySettingsNewPerDay, style: heading),
|
||||
const SizedBox(height: 8),
|
||||
CountStepper(
|
||||
value: limit(newPerDay),
|
||||
onFewer: newPerDay > StudysetStudyOptions.unlimited
|
||||
? () => _change(
|
||||
_options.copyWith(newPerDay: newPerDay - _newStep),
|
||||
)
|
||||
: null,
|
||||
onMore: newPerDay < StudysetStudyOptions.maxNewPerDay
|
||||
? () => _change(
|
||||
_options.copyWith(newPerDay: newPerDay + _newStep),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
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),
|
||||
onFewer: reviewsPerDay > StudysetStudyOptions.unlimited
|
||||
? () => _change(
|
||||
_options.copyWith(
|
||||
reviewsPerDay: reviewsPerDay - _reviewsStep,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onMore: reviewsPerDay < StudysetStudyOptions.maxReviewsPerDay
|
||||
? () => _change(
|
||||
_options.copyWith(
|
||||
reviewsPerDay: reviewsPerDay + _reviewsStep,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(l10n.studyOptionsOrder, style: heading),
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<NewCardOrder>(
|
||||
segments: [
|
||||
for (final each in NewCardOrder.values)
|
||||
ButtonSegment(
|
||||
value: each,
|
||||
label: Text(switch (each) {
|
||||
NewCardOrder.reviewsFirst => l10n.studyOptionsReviewsFirst,
|
||||
NewCardOrder.mixed => l10n.studyOptionsMixed,
|
||||
NewCardOrder.newFirst => l10n.studyOptionsNewFirst,
|
||||
}),
|
||||
),
|
||||
],
|
||||
selected: {order},
|
||||
showSelectedIcon: false,
|
||||
onSelectionChanged: (selection) =>
|
||||
_change(_options.copyWith(order: selection.single)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,9 +6,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
|
|
@ -160,7 +162,11 @@ class StudysetListBloc(
|
|||
final NoteRepository _notes,
|
||||
final LearningPathRepository _paths,
|
||||
final ReviewRepository _reviews,
|
||||
final StudysetDeletions _deletions, {
|
||||
final StudysetDeletions _deletions,
|
||||
|
||||
/// The person's settings, for the new cards a studyset without study
|
||||
/// options of its own starts a day.
|
||||
final SettingsRepository _settings, {
|
||||
|
||||
/// The studyset whose children to list; the library's top level when
|
||||
/// null.
|
||||
|
|
@ -296,6 +302,14 @@ class StudysetListBloc(
|
|||
.firstOrNull;
|
||||
final notes = await _notes.countsByStudyset();
|
||||
final paths = await _paths.countsByStudyset();
|
||||
final newPerDay = (await _settings.read()).study.newPerDay;
|
||||
StudysetStudyOptions optionsOf(String id) =>
|
||||
_studysetsRead
|
||||
.where((each) => each.id == id)
|
||||
.firstOrNull
|
||||
?.content
|
||||
.studyOptions ??
|
||||
StudysetStudyOptions(newPerDay: newPerDay);
|
||||
for (final studyset in [...studysets, ?parent]) {
|
||||
// What a session on the studyset would study; an archived one
|
||||
// rests, paused, and still counts all it holds.
|
||||
|
|
@ -309,6 +323,7 @@ class StudysetListBloc(
|
|||
progress[studyset.id] =
|
||||
studyProgress(
|
||||
groups: groups,
|
||||
options: [for (final id in studied) optionsOf(id)],
|
||||
schedules: await _reviews.schedulesOf([
|
||||
for (final group in groups) ...group,
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
|
||||
// A studyset's content as its synced row stores it (see core/content/payload).
|
||||
|
|
@ -9,24 +10,39 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_conten
|
|||
// Schema 1 held the title and the description. Schema 2 added the secondary
|
||||
// title, the colour, the licence (name, holder, year) and the languages of
|
||||
// the cards' sides, each left out when not given; schema 1 reads as schema 2
|
||||
// without them.
|
||||
// without them. Schema 3 added the study options (`study`: new cards and
|
||||
// reviews a day, 0 for no limit, and the order), left out when the studyset
|
||||
// has none.
|
||||
//
|
||||
// A studyset without study options is still written in schema 2: an app that
|
||||
// knows only schema 2 skips a schema 3 payload whole, and would lose sight of
|
||||
// every studyset edited on a newer app otherwise.
|
||||
|
||||
/// The schema this app writes.
|
||||
const studysetSchemaVersion = 2;
|
||||
/// The newest schema this app writes.
|
||||
const studysetSchemaVersion = 3;
|
||||
|
||||
/// Encodes a studyset's content into its payload.
|
||||
ByteData encodeStudysetPayload(StudysetContent content) =>
|
||||
encodePayload(studysetSchemaVersion, {
|
||||
'title': content.title,
|
||||
'description': content.description.toJson(),
|
||||
'secondaryTitle': ?content.secondaryTitle,
|
||||
'color': ?content.colorName,
|
||||
'license': ?content.license,
|
||||
'licenseHolder': ?content.licenseHolder,
|
||||
'licenseYear': ?content.licenseYear,
|
||||
'frontLanguage': ?content.frontLanguage,
|
||||
'backLanguage': ?content.backLanguage,
|
||||
});
|
||||
/// Encodes a studyset's content into its payload, in the oldest schema that
|
||||
/// holds it.
|
||||
ByteData encodeStudysetPayload(StudysetContent content) => encodePayload(
|
||||
content.studyOptions == null ? 2 : studysetSchemaVersion,
|
||||
{
|
||||
'title': content.title,
|
||||
'description': content.description.toJson(),
|
||||
'secondaryTitle': ?content.secondaryTitle,
|
||||
'color': ?content.colorName,
|
||||
'license': ?content.license,
|
||||
'licenseHolder': ?content.licenseHolder,
|
||||
'licenseYear': ?content.licenseYear,
|
||||
'frontLanguage': ?content.frontLanguage,
|
||||
'backLanguage': ?content.backLanguage,
|
||||
if (content.studyOptions case final options?)
|
||||
'study': {
|
||||
'newPerDay': options.newPerDay,
|
||||
'reviewsPerDay': options.reviewsPerDay,
|
||||
'order': options.order.name,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
/// Decodes a studyset's payload.
|
||||
///
|
||||
|
|
@ -48,6 +64,21 @@ StudysetContent decodeStudysetPayload(ByteData payload) {
|
|||
licenseYear: _optional(json, 'licenseYear'),
|
||||
frontLanguage: _optional(json, 'frontLanguage'),
|
||||
backLanguage: _optional(json, 'backLanguage'),
|
||||
studyOptions: switch (_optional<Map<String, Object?>>(json, 'study')) {
|
||||
null => null,
|
||||
final study => StudysetStudyOptions(
|
||||
newPerDay:
|
||||
_optional(study, 'newPerDay') ??
|
||||
const StudysetStudyOptions().newPerDay,
|
||||
reviewsPerDay:
|
||||
_optional(study, 'reviewsPerDay') ??
|
||||
StudysetStudyOptions.unlimited,
|
||||
// An order of a newer app reads as the default.
|
||||
order:
|
||||
NewCardOrder.values.asNameMap()[study['order']] ??
|
||||
NewCardOrder.reviewsFirst,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
throw const FormatException('Not a studyset payload.');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
|
||||
/// What a person writes about a studyset: everything its synced payload
|
||||
/// holds. Where it sits and whether it is starred or archived are columns of
|
||||
|
|
@ -36,6 +37,9 @@ final class const StudysetContent({
|
|||
|
||||
/// The language of the cards' backs, like [frontLanguage].
|
||||
final String? backLanguage,
|
||||
|
||||
/// How it is studied; null for the defaults (see [StudysetStudyOptions]).
|
||||
final StudysetStudyOptions? studyOptions,
|
||||
}) with Equatable {
|
||||
/// Content with only a [title] and an empty description, as a new
|
||||
/// studyset starts.
|
||||
|
|
@ -53,6 +57,7 @@ final class const StudysetContent({
|
|||
licenseYear,
|
||||
frontLanguage,
|
||||
backLanguage,
|
||||
studyOptions,
|
||||
];
|
||||
|
||||
/// This content with a new [title] or [description]. The other fields can
|
||||
|
|
@ -68,5 +73,21 @@ final class const StudysetContent({
|
|||
licenseYear: licenseYear,
|
||||
frontLanguage: frontLanguage,
|
||||
backLanguage: backLanguage,
|
||||
studyOptions: studyOptions,
|
||||
);
|
||||
|
||||
/// This content studied with [options]; null for the defaults.
|
||||
StudysetContent withStudyOptions(StudysetStudyOptions? options) =>
|
||||
StudysetContent(
|
||||
title: title,
|
||||
description: description,
|
||||
secondaryTitle: secondaryTitle,
|
||||
colorName: colorName,
|
||||
license: license,
|
||||
licenseHolder: licenseHolder,
|
||||
licenseYear: licenseYear,
|
||||
frontLanguage: frontLanguage,
|
||||
backLanguage: backLanguage,
|
||||
studyOptions: options,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@ 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/scroll_hiding_fab.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/delete_studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_actions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_sheet.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
|
@ -36,6 +38,7 @@ class const LibraryScreen({super.key}) extends StatelessWidget {
|
|||
person.paths,
|
||||
person.reviews,
|
||||
person.deletions,
|
||||
person.settings,
|
||||
)..add(const StudysetListEventStarted()),
|
||||
child: const _Library(),
|
||||
);
|
||||
|
|
@ -296,6 +299,16 @@ class _GridState() extends State<_Grid> {
|
|||
bloc.add(StudysetListEventContentChanged(studyset.id, content));
|
||||
}
|
||||
},
|
||||
// The grid's context, as for deleting: it holds the list's bloc and
|
||||
// outlives the card's menu.
|
||||
onMove: () async {
|
||||
final all = await PersonScope.of(this.context).studysets.all();
|
||||
if (mounted) {
|
||||
await moveStudyset(this.context, studyset, all);
|
||||
}
|
||||
},
|
||||
onStudyOptions: () => changeStudyOptions(this.context, studyset),
|
||||
onImport: () => importFromAnki(this.context, studysetId: studyset.id),
|
||||
// Archiving is tidying up, not destroying, so it doesn't ask first:
|
||||
// it offers to undo.
|
||||
onArchive: () {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/studyset_study_options_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_move_sheet.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
// What a studyset's options do the same from the library's card and from the
|
||||
// studyset's own screen, through the StudysetListBloc above them.
|
||||
|
||||
/// Asks where [studyset] goes among [all] the person's studysets, moves it
|
||||
/// there and says where it went.
|
||||
Future<void> moveStudyset(
|
||||
BuildContext context,
|
||||
Studyset studyset,
|
||||
List<Studyset> all,
|
||||
) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final bloc = context.read<StudysetListBloc>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final destination = await showMoveSheet(
|
||||
context,
|
||||
studyset: studyset,
|
||||
all: all,
|
||||
);
|
||||
if (destination == null) {
|
||||
return;
|
||||
}
|
||||
bloc.add(
|
||||
StudysetListEventMoveRequested(studyset.id, parentId: destination.parentId),
|
||||
);
|
||||
final name = switch (destination.parentId) {
|
||||
null => l10n.studysetMoveTopLevel,
|
||||
final parentId =>
|
||||
all.where((each) => each.id == parentId).firstOrNull?.content.title ?? '',
|
||||
};
|
||||
messenger
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(l10n.studysetMoved(name))));
|
||||
}
|
||||
|
||||
/// Shows [studyset]'s study options to change them, and keeps what the
|
||||
/// person saves, or goes back to the defaults.
|
||||
Future<void> changeStudyOptions(BuildContext context, Studyset studyset) async {
|
||||
final bloc = context.read<StudysetListBloc>();
|
||||
final settings = PersonScope.of(context).settings;
|
||||
final defaultNewPerDay = (await settings.read()).study.newPerDay;
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
final current = studyset.content.studyOptions;
|
||||
final choice = await showStudysetStudyOptionsSheet(
|
||||
context,
|
||||
current: current,
|
||||
defaultNewPerDay: defaultNewPerDay,
|
||||
);
|
||||
if (choice case (:final options) when options != current) {
|
||||
bloc.add(
|
||||
StudysetListEventContentChanged(
|
||||
studyset.id,
|
||||
studyset.content.withStudyOptions(options),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,15 @@ class const StudysetCard({
|
|||
/// Edits its title, colour and the like.
|
||||
required final VoidCallback onEdit,
|
||||
|
||||
/// Moves it into another studyset or to the top level.
|
||||
required final VoidCallback onMove,
|
||||
|
||||
/// Changes how it is studied.
|
||||
required final VoidCallback onStudyOptions,
|
||||
|
||||
/// Imports an Anki deck into it.
|
||||
required final VoidCallback onImport,
|
||||
|
||||
/// Archives it.
|
||||
required final VoidCallback onArchive,
|
||||
|
||||
|
|
@ -126,6 +135,9 @@ class _StudysetCardState() extends State<StudysetCard> {
|
|||
archived: archived,
|
||||
onToggleStar: widget.onToggleStar,
|
||||
onEdit: widget.onEdit,
|
||||
onMove: widget.onMove,
|
||||
onStudyOptions: widget.onStudyOptions,
|
||||
onImport: widget.onImport,
|
||||
onArchive: widget.onArchive,
|
||||
onUnarchive: widget.onUnarchive,
|
||||
onDelete: widget.onDelete,
|
||||
|
|
@ -283,6 +295,9 @@ class const _Paused() extends StatelessWidget {
|
|||
|
||||
enum _Option() {
|
||||
edit,
|
||||
move,
|
||||
studyOptions,
|
||||
import,
|
||||
archive,
|
||||
unarchive,
|
||||
delete,
|
||||
|
|
@ -295,6 +310,9 @@ class const _BottomBar({
|
|||
required final bool archived,
|
||||
required final VoidCallback onToggleStar,
|
||||
required final VoidCallback onEdit,
|
||||
required final VoidCallback onMove,
|
||||
required final VoidCallback onStudyOptions,
|
||||
required final VoidCallback onImport,
|
||||
required final VoidCallback onArchive,
|
||||
required final VoidCallback onUnarchive,
|
||||
required final VoidCallback onDelete,
|
||||
|
|
@ -331,12 +349,30 @@ class const _BottomBar({
|
|||
tooltip: l10n.studysetOptions,
|
||||
onSelected: (option) => switch (option) {
|
||||
_Option.edit => onEdit(),
|
||||
_Option.move => onMove(),
|
||||
_Option.studyOptions => onStudyOptions(),
|
||||
_Option.import => onImport(),
|
||||
_Option.archive => onArchive(),
|
||||
_Option.unarchive => onUnarchive(),
|
||||
_Option.delete => onDelete(),
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
_item(_Option.edit, LucideIcons.pencil, l10n.studysetMenuEdit),
|
||||
_item(
|
||||
_Option.move,
|
||||
LucideIcons.folderInput,
|
||||
l10n.studysetMenuMove,
|
||||
),
|
||||
_item(
|
||||
_Option.studyOptions,
|
||||
LucideIcons.slidersHorizontal,
|
||||
l10n.studyOptionsTitle,
|
||||
),
|
||||
_item(
|
||||
_Option.import,
|
||||
LucideIcons.fileInput,
|
||||
l10n.ankiImportMenu,
|
||||
),
|
||||
if (archived)
|
||||
_item(
|
||||
_Option.unarchive,
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/delete_studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/license_footer.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_actions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_breadcrumb.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_move_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_rail.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_sheet.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
|
@ -71,6 +71,7 @@ class const StudysetScreen({
|
|||
person.paths,
|
||||
person.reviews,
|
||||
person.deletions,
|
||||
person.settings,
|
||||
parentId: studyset.id,
|
||||
)..add(const StudysetListEventStarted()),
|
||||
),
|
||||
|
|
@ -834,13 +835,15 @@ class const _StudyBar({
|
|||
enum _Option() {
|
||||
edit,
|
||||
move,
|
||||
studyOptions,
|
||||
reset,
|
||||
archive,
|
||||
unarchive,
|
||||
delete,
|
||||
}
|
||||
|
||||
/// The studyset's options: edit, move, archive or unarchive, delete.
|
||||
/// The studyset's options: edit, move, its study options, reset its
|
||||
/// progress, archive or unarchive, delete.
|
||||
class const _Options({
|
||||
required final Studyset studyset,
|
||||
required final List<Studyset> all,
|
||||
|
|
@ -859,35 +862,6 @@ class const _Options({
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _move(BuildContext context) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final bloc = context.read<StudysetListBloc>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final destination = await showMoveSheet(
|
||||
context,
|
||||
studyset: studyset,
|
||||
all: all,
|
||||
);
|
||||
if (destination == null) {
|
||||
return;
|
||||
}
|
||||
bloc.add(
|
||||
StudysetListEventMoveRequested(
|
||||
studyset.id,
|
||||
parentId: destination.parentId,
|
||||
),
|
||||
);
|
||||
final name = switch (destination.parentId) {
|
||||
null => l10n.studysetMoveTopLevel,
|
||||
final parentId =>
|
||||
all.where((each) => each.id == parentId).firstOrNull?.content.title ??
|
||||
'',
|
||||
};
|
||||
messenger
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(l10n.studysetMoved(name))));
|
||||
}
|
||||
|
||||
void _archive(BuildContext context, {required bool archive}) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final bloc = context.read<StudysetListBloc>();
|
||||
|
|
@ -965,7 +939,8 @@ class const _Options({
|
|||
tooltip: l10n.studysetOptions,
|
||||
onSelected: (option) => switch (option) {
|
||||
_Option.edit => _edit(context),
|
||||
_Option.move => _move(context),
|
||||
_Option.move => moveStudyset(context, studyset, all),
|
||||
_Option.studyOptions => changeStudyOptions(context, studyset),
|
||||
_Option.reset => _reset(context),
|
||||
_Option.archive => _archive(context, archive: true),
|
||||
_Option.unarchive => _archive(context, archive: false),
|
||||
|
|
@ -978,6 +953,11 @@ class const _Options({
|
|||
itemBuilder: (context) => [
|
||||
item(_Option.edit, LucideIcons.pencil, l10n.studysetMenuEdit),
|
||||
item(_Option.move, LucideIcons.folderInput, l10n.studysetMenuMove),
|
||||
item(
|
||||
_Option.studyOptions,
|
||||
LucideIcons.slidersHorizontal,
|
||||
l10n.studyOptionsTitle,
|
||||
),
|
||||
item(_Option.reset, LucideIcons.rotateCcw, l10n.studysetMenuReset),
|
||||
if (studyset.isArchived)
|
||||
item(
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue