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:
Tizian 「ティツィアーン」 2026-10-01 19:06:38 +02:00
commit 66a1b6c2f4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 201 additions and 15 deletions

View file

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

View file

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

View file

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

View file

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