feat(app): study options in the synced settings
How the person studies, the same on every device: spaced repetition or cramming, the order (newest first for cramming only), cards per round (5 to 20), new cards a studyset starts a day (0 for no limit), and whether cards ask their back first. The settings payload's schema 2 holds them, each left out while as it comes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
baace1b6e9
commit
66a1b6c2f4
4 changed files with 201 additions and 15 deletions
|
|
@ -2,27 +2,41 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||
|
||||
// A person's settings as their synced row stores them (see
|
||||
// core/content/payload).
|
||||
//
|
||||
// Schema 1 holds the tags each studyset is studied by.
|
||||
// Schema 1 held the tags each studyset is studied by. Schema 2 added how the
|
||||
// person studies (mode, order, cards per round, new cards per day, back
|
||||
// first), each left out while as it comes; schema 1 reads with those.
|
||||
|
||||
/// The schema this app writes.
|
||||
const settingsSchemaVersion = 1;
|
||||
const settingsSchemaVersion = 2;
|
||||
|
||||
/// Encodes [settings] into their payload.
|
||||
ByteData encodeSettingsPayload(PersonSettings settings) =>
|
||||
encodePayload(settingsSchemaVersion, {
|
||||
'studyTags': {
|
||||
for (final MapEntry(key: studysetId, value: tags)
|
||||
in settings.studyTags.entries)
|
||||
studysetId: [
|
||||
for (final tag in tags) {'id': tag.id, 'name': tag.name},
|
||||
],
|
||||
},
|
||||
});
|
||||
ByteData encodeSettingsPayload(PersonSettings settings) {
|
||||
const defaults = StudyOptions();
|
||||
final study = settings.study;
|
||||
return encodePayload(settingsSchemaVersion, {
|
||||
'studyTags': {
|
||||
for (final MapEntry(key: studysetId, value: tags)
|
||||
in settings.studyTags.entries)
|
||||
studysetId: [
|
||||
for (final tag in tags) {'id': tag.id, 'name': tag.name},
|
||||
],
|
||||
},
|
||||
'study': {
|
||||
if (study.mode != defaults.mode) 'mode': study.mode.name,
|
||||
if (study.order != defaults.order) 'order': study.order.name,
|
||||
if (study.cardsPerRound != defaults.cardsPerRound)
|
||||
'cardsPerRound': study.cardsPerRound,
|
||||
if (study.newPerDay != defaults.newPerDay) 'newPerDay': study.newPerDay,
|
||||
if (study.backFirst != defaults.backFirst) 'backFirst': study.backFirst,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// Decodes a person's settings.
|
||||
///
|
||||
|
|
@ -36,6 +50,7 @@ PersonSettings decodeSettingsPayload(ByteData payload) {
|
|||
for (final MapEntry(key: studysetId, value: tags) in studyTags.entries)
|
||||
studysetId: _tagRefs(tags),
|
||||
},
|
||||
study: _study(json['study']),
|
||||
);
|
||||
}
|
||||
throw const FormatException('Not a settings payload.');
|
||||
|
|
@ -54,3 +69,30 @@ List<TagRef> _tagRefs(Object? json) => switch (json) {
|
|||
],
|
||||
_ => throw const FormatException('A settings payload has bad study tags.'),
|
||||
};
|
||||
|
||||
/// The study options in [json]; a value this app doesn't know, e.g. a mode
|
||||
/// a newer version added, reads as it comes.
|
||||
StudyOptions _study(Object? json) {
|
||||
if (json is! Map<String, Object?>) {
|
||||
return const StudyOptions();
|
||||
}
|
||||
T? named<T extends Enum>(List<T> values, Object? name) =>
|
||||
values.where((value) => value.name == name).firstOrNull;
|
||||
const defaults = StudyOptions();
|
||||
return defaults.copyWith(
|
||||
mode: named(LearnMode.values, json['mode']),
|
||||
order: named(StudyOrder.values, json['order']),
|
||||
cardsPerRound: switch (json['cardsPerRound']) {
|
||||
final int count => count,
|
||||
_ => null,
|
||||
},
|
||||
newPerDay: switch (json['newPerDay']) {
|
||||
final int count => count,
|
||||
_ => null,
|
||||
},
|
||||
backFirst: switch (json['backFirst']) {
|
||||
final bool backFirst => backFirst,
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||
|
||||
/// A person's settings that follow them to every device (ADR 0004, rule 4).
|
||||
|
|
@ -6,6 +7,9 @@ final class const PersonSettings({
|
|||
/// The tags each studyset is studied by, by the studyset's id: its cards
|
||||
/// with any of them. A studyset not listed studies all its cards.
|
||||
final Map<String, List<TagRef>> studyTags = const {},
|
||||
|
||||
/// How the person studies.
|
||||
final StudyOptions study = const StudyOptions(),
|
||||
}) with Equatable {
|
||||
/// The tags the studyset [studysetId] is studied by; none for all its
|
||||
/// cards.
|
||||
|
|
@ -21,13 +25,21 @@ final class const PersonSettings({
|
|||
if (key != studysetId) key: value,
|
||||
if (tags.isNotEmpty) studysetId: tags,
|
||||
},
|
||||
study: study,
|
||||
);
|
||||
|
||||
/// These settings with [study] as how the person studies.
|
||||
PersonSettings withStudy(StudyOptions study) =>
|
||||
PersonSettings(studyTags: studyTags, study: study);
|
||||
|
||||
/// These settings with what only [other] has added: where both have a
|
||||
/// setting, these win. Two devices' settings fold so.
|
||||
PersonSettings foldedWith(PersonSettings other) =>
|
||||
PersonSettings(studyTags: {...other.studyTags, ...studyTags});
|
||||
PersonSettings foldedWith(PersonSettings other) => PersonSettings(
|
||||
studyTags: {...other.studyTags, ...studyTags},
|
||||
// Options left as they come count as none.
|
||||
study: study == const StudyOptions() ? other.study : study,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [studyTags];
|
||||
List<Object?> get props => [studyTags, study];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// How a study session goes.
|
||||
enum LearnMode() {
|
||||
/// Spaced repetition: due cards on their schedules, ratings move them.
|
||||
spaced,
|
||||
|
||||
/// Every card once, outside the schedules: ratings move none.
|
||||
cram,
|
||||
}
|
||||
|
||||
/// The order a session's cards come in.
|
||||
enum StudyOrder() {
|
||||
/// Spaced repetition: the most overdue first; cramming: in the
|
||||
/// studyset's order.
|
||||
oldestFirst,
|
||||
|
||||
/// Shuffled.
|
||||
random,
|
||||
|
||||
/// Cramming only: the studyset's order backwards, the newest first.
|
||||
newestFirst,
|
||||
}
|
||||
|
||||
/// How the person studies, the same on every device (in their settings).
|
||||
final class const StudyOptions({
|
||||
/// Spaced repetition or cramming.
|
||||
final LearnMode mode = LearnMode.spaced,
|
||||
|
||||
/// The order of the cards.
|
||||
final StudyOrder order = StudyOrder.oldestFirst,
|
||||
|
||||
/// How many cards a round holds, from [minCardsPerRound] to
|
||||
/// [maxCardsPerRound].
|
||||
final int cardsPerRound = 20,
|
||||
|
||||
/// How many new cards a studyset starts a day; [unlimitedNewPerDay] for
|
||||
/// no limit.
|
||||
final int newPerDay = 20,
|
||||
|
||||
/// Whether front/back and vocabulary cards ask their back, on a schedule
|
||||
/// of their own (ADR 0004, rule 3).
|
||||
final bool backFirst = false,
|
||||
}) with Equatable {
|
||||
/// The fewest cards a round holds.
|
||||
static const minCardsPerRound = 5;
|
||||
|
||||
/// The most cards a round holds.
|
||||
static const maxCardsPerRound = 20;
|
||||
|
||||
/// [newPerDay] for no limit.
|
||||
static const unlimitedNewPerDay = 0;
|
||||
|
||||
/// The highest daily limit of new cards.
|
||||
static const maxNewPerDay = 100;
|
||||
|
||||
/// The order this session really uses: newest first is for cramming.
|
||||
StudyOrder get effectiveOrder =>
|
||||
mode == LearnMode.spaced && order == StudyOrder.newestFirst
|
||||
? StudyOrder.oldestFirst
|
||||
: order;
|
||||
|
||||
/// These options with some replaced, the numbers kept in their ranges.
|
||||
StudyOptions copyWith({
|
||||
LearnMode? mode,
|
||||
StudyOrder? order,
|
||||
int? cardsPerRound,
|
||||
int? newPerDay,
|
||||
bool? backFirst,
|
||||
}) => StudyOptions(
|
||||
mode: mode ?? this.mode,
|
||||
order: order ?? this.order,
|
||||
cardsPerRound: (cardsPerRound ?? this.cardsPerRound).clamp(
|
||||
minCardsPerRound,
|
||||
maxCardsPerRound,
|
||||
),
|
||||
newPerDay: (newPerDay ?? this.newPerDay).clamp(
|
||||
unlimitedNewPerDay,
|
||||
maxNewPerDay,
|
||||
),
|
||||
backFirst: backFirst ?? this.backFirst,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [mode, order, cardsPerRound, newPerDay, backFirst];
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
|
||||
|
||||
void main() {
|
||||
const verb = (id: 'tag-1', name: 'verb');
|
||||
|
|
@ -49,4 +51,48 @@ void main() {
|
|||
|
||||
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
|
||||
});
|
||||
|
||||
test('study options survive a round trip, and a payload of the first '
|
||||
'schema reads with them as they come', () {
|
||||
const settings = PersonSettings(
|
||||
study: StudyOptions(
|
||||
mode: LearnMode.cram,
|
||||
order: StudyOrder.random,
|
||||
cardsPerRound: 10,
|
||||
newPerDay: 0,
|
||||
backFirst: true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
|
||||
expect(
|
||||
decodeSettingsPayload(encodePayload(1, {'studyTags': {}})).study,
|
||||
const StudyOptions(),
|
||||
);
|
||||
});
|
||||
|
||||
test('study options keep their numbers in range', () {
|
||||
final options = const StudyOptions().copyWith(
|
||||
cardsPerRound: 50,
|
||||
newPerDay: -5,
|
||||
);
|
||||
|
||||
expect(options.cardsPerRound, StudyOptions.maxCardsPerRound);
|
||||
expect(options.newPerDay, StudyOptions.unlimitedNewPerDay);
|
||||
});
|
||||
|
||||
test('newest first is for cramming; spaced repetition goes oldest '
|
||||
'first', () {
|
||||
expect(
|
||||
const StudyOptions(order: StudyOrder.newestFirst).effectiveOrder,
|
||||
StudyOrder.oldestFirst,
|
||||
);
|
||||
expect(
|
||||
const StudyOptions(
|
||||
mode: LearnMode.cram,
|
||||
order: StudyOrder.newestFirst,
|
||||
).effectiveOrder,
|
||||
StudyOrder.newestFirst,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue