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/core/content/payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.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';
|
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||||
|
|
||||||
// A person's settings as their synced row stores them (see
|
// A person's settings as their synced row stores them (see
|
||||||
// core/content/payload).
|
// 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.
|
/// The schema this app writes.
|
||||||
const settingsSchemaVersion = 1;
|
const settingsSchemaVersion = 2;
|
||||||
|
|
||||||
/// Encodes [settings] into their payload.
|
/// Encodes [settings] into their payload.
|
||||||
ByteData encodeSettingsPayload(PersonSettings settings) =>
|
ByteData encodeSettingsPayload(PersonSettings settings) {
|
||||||
encodePayload(settingsSchemaVersion, {
|
const defaults = StudyOptions();
|
||||||
'studyTags': {
|
final study = settings.study;
|
||||||
for (final MapEntry(key: studysetId, value: tags)
|
return encodePayload(settingsSchemaVersion, {
|
||||||
in settings.studyTags.entries)
|
'studyTags': {
|
||||||
studysetId: [
|
for (final MapEntry(key: studysetId, value: tags)
|
||||||
for (final tag in tags) {'id': tag.id, 'name': tag.name},
|
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.
|
/// Decodes a person's settings.
|
||||||
///
|
///
|
||||||
|
|
@ -36,6 +50,7 @@ PersonSettings decodeSettingsPayload(ByteData payload) {
|
||||||
for (final MapEntry(key: studysetId, value: tags) in studyTags.entries)
|
for (final MapEntry(key: studysetId, value: tags) in studyTags.entries)
|
||||||
studysetId: _tagRefs(tags),
|
studysetId: _tagRefs(tags),
|
||||||
},
|
},
|
||||||
|
study: _study(json['study']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw const FormatException('Not a settings payload.');
|
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.'),
|
_ => 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: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';
|
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).
|
/// 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
|
/// 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.
|
/// with any of them. A studyset not listed studies all its cards.
|
||||||
final Map<String, List<TagRef>> studyTags = const {},
|
final Map<String, List<TagRef>> studyTags = const {},
|
||||||
|
|
||||||
|
/// How the person studies.
|
||||||
|
final StudyOptions study = const StudyOptions(),
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
/// The tags the studyset [studysetId] is studied by; none for all its
|
/// The tags the studyset [studysetId] is studied by; none for all its
|
||||||
/// cards.
|
/// cards.
|
||||||
|
|
@ -21,13 +25,21 @@ final class const PersonSettings({
|
||||||
if (key != studysetId) key: value,
|
if (key != studysetId) key: value,
|
||||||
if (tags.isNotEmpty) studysetId: tags,
|
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
|
/// These settings with what only [other] has added: where both have a
|
||||||
/// setting, these win. Two devices' settings fold so.
|
/// setting, these win. Two devices' settings fold so.
|
||||||
PersonSettings foldedWith(PersonSettings other) =>
|
PersonSettings foldedWith(PersonSettings other) => PersonSettings(
|
||||||
PersonSettings(studyTags: {...other.studyTags, ...studyTags});
|
studyTags: {...other.studyTags, ...studyTags},
|
||||||
|
// Options left as they come count as none.
|
||||||
|
study: study == const StudyOptions() ? other.study : study,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@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: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/data/settings_payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.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() {
|
void main() {
|
||||||
const verb = (id: 'tag-1', name: 'verb');
|
const verb = (id: 'tag-1', name: 'verb');
|
||||||
|
|
@ -49,4 +51,48 @@ void main() {
|
||||||
|
|
||||||
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
|
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