feat: learning paths in studysets, synced, with progress and tags
Server: five synced tables, only additions (migration
20261002211346701-learning-paths):
- `learning_path`: its studyset, a fractional position, and its own
content in the payload {title, secondaryTitle, description}, the last
writer winning, as a studyset's;
- `learning_path_step`: its path, a position, and its kind ('info',
'singleChoice', 'multipleChoice', 'dragAndDrop', 'fillInTheBlank'),
fixed once created;
- `learning_path_step_revision`: a step's content as append-only
revisions, as a card's: {title, body, exercise, rightFeedback,
wrongFeedback, sources};
- `learning_path_answer`: an append-only log (right, wrong, continued,
reset), as reviews are, so answers from two devices never conflict;
- `learning_path_tag`: one row per assignment, as `card_tag`.
App, `feature/learning_path`: the five exercises and their JSON, grading
that marks only the learner's own wrong picks, progress worked out from
the log (a step is done once answered right since its last fresh start),
`LearningPathRepository` (steps saved on what they started from, merged
per field) and `PathProgressRepository` (answers, resets with Undo, the
times steps were done). Paths share the person's tags; deleting a path
or a step can be undone for a moment, and a studyset's deletion takes
its paths. Tests: models, codecs, repositories, tags, the device
migration guard, and sync tests against the server (four).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
43834ef42b
commit
91f6376b93
39 changed files with 34455 additions and 26 deletions
|
|
@ -21,6 +21,11 @@ import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/sync_card_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/sync_learning_path_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/sync_path_answer_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/sync_note_gateway.dart';
|
||||
|
|
@ -159,15 +164,22 @@ Future<PersonDependencies> _openPerson(
|
|||
final notes = NoteRepository(
|
||||
SyncNoteGateway(db, sync, person.authUserId),
|
||||
);
|
||||
final paths = LearningPathRepository(
|
||||
SyncLearningPathGateway(db, sync, person.authUserId),
|
||||
);
|
||||
final pathProgress = PathProgressRepository(SyncPathAnswerGateway(db, sync));
|
||||
final tags = TagRepository(
|
||||
SyncTagGateway(db, sync),
|
||||
cardChanges: cards.changes,
|
||||
noteChanges: notes.changes,
|
||||
pathChanges: paths.changes,
|
||||
);
|
||||
final settings = SettingsRepository(SyncSettingsGateway(db, sync));
|
||||
final reviews = ReviewRepository(SyncReviewGateway(db, sync));
|
||||
final deletions = StudysetDeletions(studysets, cards, notes);
|
||||
final deletions = StudysetDeletions(studysets, cards, notes, paths);
|
||||
final noteDeletions = NoteDeletions(notes);
|
||||
final pathDeletions = LearningPathDeletions(paths);
|
||||
final stepDeletions = StepDeletions(paths);
|
||||
final assets = AssetRepository(
|
||||
SyncAssetGateway(db, sync),
|
||||
ServerpodAssetServerGateway(client),
|
||||
|
|
@ -179,24 +191,32 @@ Future<PersonDependencies> _openPerson(
|
|||
studysets: studysets,
|
||||
cards: cards,
|
||||
notes: notes,
|
||||
paths: paths,
|
||||
pathProgress: pathProgress,
|
||||
reviews: reviews,
|
||||
tags: tags,
|
||||
settings: settings,
|
||||
assets: assets,
|
||||
deletions: deletions,
|
||||
noteDeletions: noteDeletions,
|
||||
pathDeletions: pathDeletions,
|
||||
stepDeletions: stepDeletions,
|
||||
onDispose: () async {
|
||||
try {
|
||||
// Deletions first: those still waiting are dropped, those under way
|
||||
// finish while their repositories are open.
|
||||
await deletions.dispose();
|
||||
await noteDeletions.dispose();
|
||||
await pathDeletions.dispose();
|
||||
await stepDeletions.dispose();
|
||||
await studysets.dispose();
|
||||
await tags.dispose();
|
||||
await settings.dispose();
|
||||
await reviews.dispose();
|
||||
await cards.dispose();
|
||||
await notes.dispose();
|
||||
await paths.dispose();
|
||||
await pathProgress.dispose();
|
||||
await assets.dispose();
|
||||
await sync.dispose();
|
||||
await db.close();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.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_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_deletions.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';
|
||||
|
|
@ -23,10 +26,16 @@ class PersonDependencies({
|
|||
/// The person's notes.
|
||||
required final NoteRepository notes,
|
||||
|
||||
/// The person's learning paths and their steps.
|
||||
required final LearningPathRepository paths,
|
||||
|
||||
/// Where the person stands in their learning paths.
|
||||
required final PathProgressRepository pathProgress,
|
||||
|
||||
/// The person's reviews.
|
||||
required final ReviewRepository reviews,
|
||||
|
||||
/// The person's tags and which cards and notes have them.
|
||||
/// The person's tags and which cards, notes and paths have them.
|
||||
required final TagRepository tags,
|
||||
|
||||
/// The person's settings, which follow them to every device.
|
||||
|
|
@ -40,6 +49,12 @@ class PersonDependencies({
|
|||
|
||||
/// The note deletions that can still be undone.
|
||||
required final NoteDeletions noteDeletions,
|
||||
|
||||
/// The learning path deletions that can still be undone.
|
||||
required final LearningPathDeletions pathDeletions,
|
||||
|
||||
/// The deletions of paths' steps that can still be undone.
|
||||
required final StepDeletions stepDeletions,
|
||||
final Future<void> Function()? _onDispose,
|
||||
}) {
|
||||
/// Closes what was opened for the person.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import 'package:halkyon_learn_flutter/src/core/async/delayed_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
|
||||
/// The person's deletions of learning paths, which can be undone for a
|
||||
/// moment (see [DelayedDeletions]); once the time to undo is over, the path
|
||||
/// is blanked and deleted with its steps.
|
||||
class LearningPathDeletions(LearningPathRepository paths, {super.undoWindow})
|
||||
extends DelayedDeletions {
|
||||
this : super(paths.deletePath, logName: 'Paths');
|
||||
}
|
||||
|
||||
/// The person's deletions of steps of learning paths, which can be undone
|
||||
/// for a moment (see [DelayedDeletions]); once the time to undo is over, the
|
||||
/// step is blanked and deleted with its answers.
|
||||
class StepDeletions(LearningPathRepository paths, {super.undoWindow})
|
||||
extends DelayedDeletions {
|
||||
this : super(paths.deleteStep, logName: 'Paths');
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
/// A path's row as the device's database stores it.
|
||||
typedef PathRow = ({
|
||||
String id,
|
||||
String studysetId,
|
||||
String position,
|
||||
ByteData payload,
|
||||
});
|
||||
|
||||
/// A step's row as the device's database stores it.
|
||||
typedef StepRow = ({String id, String pathId, String position, String kind});
|
||||
|
||||
/// A step revision's row as the device's database stores it.
|
||||
typedef StepRevisionRow = ({
|
||||
String id,
|
||||
String stepId,
|
||||
Set<String> parents,
|
||||
ByteData payload,
|
||||
});
|
||||
|
||||
/// The boundary to the learning paths and their steps in this person's local,
|
||||
/// synced database. Tests replace it with a fake.
|
||||
abstract interface class LearningPathGateway() {
|
||||
/// The paths of the studyset [studysetId], unordered; every path's without.
|
||||
Future<List<PathRow>> paths({String? studysetId});
|
||||
|
||||
/// Stores a new path; returns its id.
|
||||
Future<String> insertPath({
|
||||
required String studysetId,
|
||||
required String position,
|
||||
required ByteData payload,
|
||||
});
|
||||
|
||||
/// Replaces the path [id]'s payload; the only column that changes.
|
||||
Future<void> updatePathPayload(String id, ByteData payload);
|
||||
|
||||
/// Moves the path [id] to [position]; the only column that changes.
|
||||
Future<void> updatePathPosition(String id, String position);
|
||||
|
||||
/// Deletes the path [id] and its tags; its steps go first, through
|
||||
/// [deleteStep].
|
||||
Future<void> deletePath(String id);
|
||||
|
||||
/// The steps of the paths [pathIds], unordered.
|
||||
Future<List<StepRow>> steps(Set<String> pathIds);
|
||||
|
||||
/// The revisions of the steps [stepIds], unordered.
|
||||
Future<List<StepRevisionRow>> revisions(Set<String> stepIds);
|
||||
|
||||
/// Stores a new step with its first revision; returns both ids.
|
||||
Future<({String stepId, String revisionId})> insertStep({
|
||||
required String pathId,
|
||||
required String position,
|
||||
required String kind,
|
||||
required ByteData payload,
|
||||
});
|
||||
|
||||
/// Appends a revision to the step [stepId] that replaces [parents];
|
||||
/// returns its id.
|
||||
Future<String> insertRevision({
|
||||
required String stepId,
|
||||
required Set<String> parents,
|
||||
required ByteData payload,
|
||||
});
|
||||
|
||||
/// Moves the step [id] to [position]; the only column that changes.
|
||||
Future<void> updateStepPosition(String id, String position);
|
||||
|
||||
/// Blanks the payload of every revision of the step [stepId], and of the
|
||||
/// path [pathId] when given.
|
||||
Future<void> blank({String? stepId, String? pathId});
|
||||
|
||||
/// Deletes the step [id], its revisions and its answers.
|
||||
Future<void> deleteStep(String id);
|
||||
|
||||
/// Fires when a sync brought changes from the person's other devices.
|
||||
Stream<void> get remoteChanges;
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
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/learning_path/model/learning_path.dart';
|
||||
|
||||
// A learning path's content as its synced row stores it (see
|
||||
// core/content/payload). Schema 1: the title, the secondary title (left out
|
||||
// when none) and the description.
|
||||
|
||||
/// The schema this app writes.
|
||||
const learningPathSchemaVersion = 1;
|
||||
|
||||
/// Encodes a path's content into its payload.
|
||||
ByteData encodeLearningPathPayload(LearningPathContent content) =>
|
||||
encodePayload(learningPathSchemaVersion, {
|
||||
'title': content.title,
|
||||
'secondaryTitle': ?content.secondaryTitle,
|
||||
'description': content.description.toJson(),
|
||||
});
|
||||
|
||||
/// Decodes a path's payload.
|
||||
///
|
||||
/// Throws a [FormatException] for anything else, including content written
|
||||
/// by a newer app version in a schema this one doesn't know.
|
||||
LearningPathContent decodeLearningPathPayload(ByteData payload) {
|
||||
final json = decodePayload(payload, newestSchema: learningPathSchemaVersion);
|
||||
if (json case {
|
||||
'title': final String title,
|
||||
'description': final Map<String, Object?> description,
|
||||
}) {
|
||||
return LearningPathContent(
|
||||
title: title,
|
||||
secondaryTitle: switch (json['secondaryTitle']) {
|
||||
null => null,
|
||||
final String secondary => secondary,
|
||||
_ => throw const FormatException('A malformed secondary title.'),
|
||||
},
|
||||
description: RichContent.fromJson(description),
|
||||
);
|
||||
}
|
||||
throw const FormatException('Not a learning path payload.');
|
||||
}
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:fractional_indexing_dart/fractional_indexing_dart.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_step_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
|
||||
|
||||
/// The person's learning paths and their steps (decided 2026-10-02): a
|
||||
/// path's own content in its row, the last writer winning, as a studyset's;
|
||||
/// a step's content as append-only revisions merged per field (ADR 0003), as
|
||||
/// a card's; both ordered by a fractional index.
|
||||
class LearningPathRepository(final LearningPathGateway _gateway) {
|
||||
this {
|
||||
_remoteChanges = _gateway.remoteChanges.listen((_) => _changes.add(null));
|
||||
}
|
||||
|
||||
final _changes = StreamController<void>.broadcast();
|
||||
late final StreamSubscription<void> _remoteChanges;
|
||||
|
||||
/// Fires after every change of any path or step, made on this device or
|
||||
/// synced from another.
|
||||
Stream<void> get changes => _changes.stream;
|
||||
|
||||
/// The paths of [studysetId] in order, now and after every change.
|
||||
Stream<List<LearningPath>> watchPaths(String studysetId) =>
|
||||
_watch(() => pathsOf(studysetId), 'paths');
|
||||
|
||||
/// The paths of [studysetId] on this device right now, in order.
|
||||
Future<List<LearningPath>> pathsOf(String studysetId) async => [
|
||||
for (final row in await _gateway.paths(studysetId: studysetId))
|
||||
?_decodePath(row),
|
||||
]..sort((a, b) => _byPosition(a.position, a.id, b.position, b.id));
|
||||
|
||||
/// How many paths each studyset holds, by the studyset's id; studysets
|
||||
/// without paths are left out.
|
||||
Future<Map<String, int>> countsByStudyset() async {
|
||||
final counts = <String, int>{};
|
||||
for (final path in await _gateway.paths()) {
|
||||
counts.update(path.studysetId, (count) => count + 1, ifAbsent: () => 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/// Creates a path with [content] at the end of [studysetId]; returns its
|
||||
/// id.
|
||||
Future<String> createPath(
|
||||
String studysetId,
|
||||
LearningPathContent content,
|
||||
) async {
|
||||
final id = await _gateway.insertPath(
|
||||
studysetId: studysetId,
|
||||
position: FractionalIndexing.generateKeyBetween(
|
||||
_last([
|
||||
for (final row in await _gateway.paths(studysetId: studysetId))
|
||||
row.position,
|
||||
]),
|
||||
null,
|
||||
),
|
||||
payload: encodeLearningPathPayload(content.normalized),
|
||||
);
|
||||
_changes.add(null);
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Replaces the path [id]'s content; the last writer wins.
|
||||
Future<void> updatePath(String id, LearningPathContent content) async {
|
||||
await _gateway.updatePathPayload(
|
||||
id,
|
||||
encodeLearningPathPayload(content.normalized),
|
||||
);
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// Moves the path [id] to [toIndex] among the paths of [studysetId].
|
||||
Future<void> movePath(
|
||||
String studysetId,
|
||||
String id, {
|
||||
required int toIndex,
|
||||
}) async {
|
||||
final others = [
|
||||
for (final path in await pathsOf(studysetId))
|
||||
if (path.id != id) (id: path.id, position: path.position),
|
||||
];
|
||||
await _gateway.updatePathPosition(id, _between(others, toIndex));
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// Deletes the path [id] with its steps, each blanked first (ADR 0002,
|
||||
/// rule 2), then the path's own content.
|
||||
Future<void> deletePath(String id) async {
|
||||
await _deletePathNow(id);
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// Deletes every path of [studysetId] as [deletePath] does, before the
|
||||
/// studyset itself goes.
|
||||
Future<void> deleteAllOf(String studysetId) async {
|
||||
for (final path in await _gateway.paths(studysetId: studysetId)) {
|
||||
await _deletePathNow(path.id);
|
||||
}
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// The steps of [pathId] in order, now and after every change.
|
||||
Stream<List<LearningPathStep>> watchSteps(String pathId) =>
|
||||
_watch(() => stepsOf(pathId), 'steps');
|
||||
|
||||
/// The steps of [pathId] on this device right now, in order; steps of a
|
||||
/// kind this app doesn't know, or that it can't read, are left out.
|
||||
Future<List<LearningPathStep>> stepsOf(String pathId) async {
|
||||
final rows = await _gateway.steps({pathId});
|
||||
final kinds = {for (final row in rows) row.id: ?StepKind.of(row.kind)};
|
||||
final revisionsByStep = <String, List<StepRevision>>{};
|
||||
for (final row in await _gateway.revisions(kinds.keys.toSet())) {
|
||||
if (_decodeRevision(row, kinds[row.stepId]!) case final revision?) {
|
||||
(revisionsByStep[row.stepId] ??= []).add(revision);
|
||||
}
|
||||
}
|
||||
return [
|
||||
for (final row in rows)
|
||||
if (kinds[row.id] case final kind?)
|
||||
if (mergeStepRevisions(revisionsByStep[row.id] ?? const [])
|
||||
case final merged?)
|
||||
LearningPathStep(
|
||||
id: row.id,
|
||||
position: row.position,
|
||||
kind: kind,
|
||||
content: merged.content,
|
||||
heads: merged.heads,
|
||||
conflicts: merged.conflicts,
|
||||
),
|
||||
]..sort((a, b) => _byPosition(a.position, a.id, b.position, b.id));
|
||||
}
|
||||
|
||||
/// Creates a step of [kind] with [content] at the end of [pathId]; returns
|
||||
/// its id and its first revision's, for the next edit to build on.
|
||||
Future<({String stepId, String revisionId})> createStep(
|
||||
String pathId,
|
||||
StepKind kind,
|
||||
StepContent content,
|
||||
) async {
|
||||
final ids = await _gateway.insertStep(
|
||||
pathId: pathId,
|
||||
position: FractionalIndexing.generateKeyBetween(
|
||||
_last([
|
||||
for (final row in await _gateway.steps({pathId})) row.position,
|
||||
]),
|
||||
null,
|
||||
),
|
||||
kind: kind.value,
|
||||
payload: encodeStepPayload(content.normalized),
|
||||
);
|
||||
_changes.add(null);
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// Stores [content] as the next revision of the step [stepId], replacing
|
||||
/// [basedOn]: the revisions the edit started from (all current heads to
|
||||
/// settle a conflict), so another device's edit merges with it (ADR 0003).
|
||||
/// Returns the new revision's id.
|
||||
Future<String> saveStep(
|
||||
String stepId,
|
||||
StepContent content, {
|
||||
required Set<String> basedOn,
|
||||
}) async {
|
||||
final id = await _gateway.insertRevision(
|
||||
stepId: stepId,
|
||||
parents: basedOn,
|
||||
payload: encodeStepPayload(content.normalized),
|
||||
);
|
||||
_changes.add(null);
|
||||
return id;
|
||||
}
|
||||
|
||||
/// Moves the step [id] to [toIndex] among the steps of [pathId].
|
||||
Future<void> moveStep(
|
||||
String pathId,
|
||||
String id, {
|
||||
required int toIndex,
|
||||
}) async {
|
||||
final others = [
|
||||
for (final row in await _gateway.steps({pathId}))
|
||||
if (row.id != id) (id: row.id, position: row.position),
|
||||
]..sort((a, b) => _byPosition(a.position, a.id, b.position, b.id));
|
||||
await _gateway.updateStepPosition(id, _between(others, toIndex));
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// Deletes the step [id], its revisions blanked first (ADR 0002, rule 2).
|
||||
Future<void> deleteStep(String id) async {
|
||||
await _gateway.blank(stepId: id);
|
||||
await _gateway.deleteStep(id);
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// Stops following remote changes.
|
||||
Future<void> dispose() async {
|
||||
await _remoteChanges.cancel();
|
||||
await _changes.close();
|
||||
}
|
||||
|
||||
Future<void> _deletePathNow(String id) async {
|
||||
for (final step in await _gateway.steps({id})) {
|
||||
await _gateway.blank(stepId: step.id);
|
||||
await _gateway.deleteStep(step.id);
|
||||
}
|
||||
await _gateway.blank(pathId: id);
|
||||
await _gateway.deletePath(id);
|
||||
}
|
||||
|
||||
/// [read] now and after every change, one read after another, so an older
|
||||
/// list never overtakes a newer one. A failed read arrives as an error;
|
||||
/// the next change reads again.
|
||||
Stream<T> _watch<T>(Future<T> Function() read, String what) =>
|
||||
Stream.multi((listener) {
|
||||
var cancelled = false;
|
||||
var reading = Future<void>.value();
|
||||
void next() => reading = reading.then((_) async {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final value = await read();
|
||||
if (!cancelled) {
|
||||
listener.add(value);
|
||||
}
|
||||
} on Object catch (error, st) {
|
||||
log('Reading $what failed', name: 'Paths', error: error);
|
||||
if (!cancelled) {
|
||||
listener.addError(error, st);
|
||||
}
|
||||
}
|
||||
});
|
||||
final subscription = _changes.stream.listen((_) => next());
|
||||
listener.onCancel = () {
|
||||
cancelled = true;
|
||||
return subscription.cancel();
|
||||
};
|
||||
next();
|
||||
});
|
||||
|
||||
LearningPath? _decodePath(PathRow row) {
|
||||
try {
|
||||
return LearningPath(
|
||||
id: row.id,
|
||||
studysetId: row.studysetId,
|
||||
position: row.position,
|
||||
content: decodeLearningPathPayload(row.payload),
|
||||
);
|
||||
} on FormatException catch (error) {
|
||||
log('Skipped path ${row.id}: ${error.message}', name: 'Paths');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
StepRevision? _decodeRevision(StepRevisionRow row, StepKind kind) {
|
||||
try {
|
||||
return StepRevision(
|
||||
id: row.id,
|
||||
parents: row.parents,
|
||||
content: decodeStepPayload(row.payload, kind),
|
||||
);
|
||||
} on FormatException catch (error) {
|
||||
log('Skipped step revision ${row.id}: ${error.message}', name: 'Paths');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The greatest of [positions], or null for none.
|
||||
String? _last(Iterable<String> positions) {
|
||||
String? last;
|
||||
for (final position in positions) {
|
||||
if (last == null || position.compareTo(last) > 0) {
|
||||
last = position;
|
||||
}
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/// A position at [index] among [others], in order. Two devices appending
|
||||
/// at once can pick the same position; the key then goes after every item
|
||||
/// sharing the lower one.
|
||||
String _between(List<({String id, String position})> others, int index) {
|
||||
final at = index.clamp(0, others.length);
|
||||
final lower = at > 0 ? others[at - 1].position : null;
|
||||
String? upper;
|
||||
for (final other in others.skip(at)) {
|
||||
if (lower == null || other.position.compareTo(lower) > 0) {
|
||||
upper = other.position;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return FractionalIndexing.generateKeyBetween(lower, upper);
|
||||
}
|
||||
|
||||
/// By position; ties by id, which sorts by creation.
|
||||
int _byPosition(String a, String aId, String b, String bId) =>
|
||||
switch (a.compareTo(b)) {
|
||||
0 => aId.compareTo(bId),
|
||||
final order => order,
|
||||
};
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
import 'dart:convert';
|
||||
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/learning_path/model/answer.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
|
||||
|
||||
// A step revision's content as its synced row stores it (see
|
||||
// core/content/payload). Schema 1: {title, body, exercise, rightFeedback,
|
||||
// wrongFeedback, sources}. The exercise's JSON is its kind's, which the step's
|
||||
// row holds:
|
||||
//
|
||||
// - single choice: {"options": ["…"], "correct": 0}
|
||||
// - multiple choice: {"options": ["…"], "correct": [0, 2]}
|
||||
// - drag and drop: {"targets": ["…"], "items": [{"text": "…", "target": 0}]}
|
||||
// - fill in the blank: {"passage": "… ___ …", "blanks": ["…"]}
|
||||
//
|
||||
// An info step has none. A source is {"authors", "year", "title", "url"}, each
|
||||
// left out when empty but the title.
|
||||
|
||||
/// The schema this app writes.
|
||||
const stepSchemaVersion = 1;
|
||||
|
||||
/// Encodes a step's content into a revision's payload.
|
||||
ByteData encodeStepPayload(StepContent content) =>
|
||||
encodePayload(stepSchemaVersion, {
|
||||
'title': content.title,
|
||||
'body': content.body.toJson(),
|
||||
'exercise': ?switch (content.exercise) {
|
||||
null => null,
|
||||
final exercise => exerciseToJson(exercise),
|
||||
},
|
||||
'rightFeedback': content.rightFeedback.toJson(),
|
||||
'wrongFeedback': content.wrongFeedback.toJson(),
|
||||
'sources': [
|
||||
for (final source in content.sources)
|
||||
{
|
||||
if (source.authors.isNotEmpty) 'authors': source.authors,
|
||||
if (source.year.isNotEmpty) 'year': source.year,
|
||||
'title': source.title,
|
||||
if (source.url.isNotEmpty) 'url': source.url,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/// Decodes a step revision's payload, its exercise as one of [kind]'s.
|
||||
///
|
||||
/// Throws a [FormatException] for anything else, including content written
|
||||
/// by a newer app version in a schema this one doesn't know.
|
||||
StepContent decodeStepPayload(ByteData payload, StepKind kind) {
|
||||
final json = decodePayload(payload, newestSchema: stepSchemaVersion);
|
||||
if (json case {
|
||||
'title': final String title,
|
||||
'body': final Map<String, Object?> body,
|
||||
'rightFeedback': final Map<String, Object?> rightFeedback,
|
||||
'wrongFeedback': final Map<String, Object?> wrongFeedback,
|
||||
'sources': final List<Object?> sources,
|
||||
}) {
|
||||
return StepContent(
|
||||
title: title,
|
||||
body: RichContent.fromJson(body),
|
||||
exercise: switch (json['exercise']) {
|
||||
null when !kind.isExercise => null,
|
||||
final Object exercise when kind.isExercise => exerciseFromJson(
|
||||
kind,
|
||||
exercise,
|
||||
),
|
||||
_ => throw FormatException('A $kind step with a malformed exercise.'),
|
||||
},
|
||||
rightFeedback: RichContent.fromJson(rightFeedback),
|
||||
wrongFeedback: RichContent.fromJson(wrongFeedback),
|
||||
sources: [for (final source in sources) _source(source)],
|
||||
);
|
||||
}
|
||||
throw const FormatException('Not a learning path step payload.');
|
||||
}
|
||||
|
||||
/// [exercise] as its kind's JSON.
|
||||
Map<String, Object?> exerciseToJson(Exercise exercise) => switch (exercise) {
|
||||
SingleChoice(:final options, :final correct) => {
|
||||
'options': options,
|
||||
'correct': correct,
|
||||
},
|
||||
MultipleChoice(:final options, :final correct) => {
|
||||
'options': options,
|
||||
'correct': [...correct]..sort(),
|
||||
},
|
||||
DragAndDrop(:final targets, :final items) => {
|
||||
'targets': targets,
|
||||
'items': [
|
||||
for (final item in items) {'text': item.text, 'target': item.target},
|
||||
],
|
||||
},
|
||||
FillInTheBlank(:final passage, :final blanks) => {
|
||||
'passage': passage,
|
||||
'blanks': blanks,
|
||||
},
|
||||
};
|
||||
|
||||
/// The exercise of [kind] in [json]; a [FormatException] for anything else.
|
||||
Exercise exerciseFromJson(StepKind kind, Object json) => switch ((kind, json)) {
|
||||
(
|
||||
StepKind.singleChoice,
|
||||
{'options': final List<Object?> options, 'correct': final int? correct},
|
||||
) =>
|
||||
SingleChoice(options: _strings(options), correct: correct),
|
||||
(
|
||||
StepKind.multipleChoice,
|
||||
{
|
||||
'options': final List<Object?> options,
|
||||
'correct': final List<Object?> correct,
|
||||
},
|
||||
) =>
|
||||
MultipleChoice(options: _strings(options), correct: _ints(correct).toSet()),
|
||||
(
|
||||
StepKind.dragAndDrop,
|
||||
{
|
||||
'targets': final List<Object?> targets,
|
||||
'items': final List<Object?> items,
|
||||
},
|
||||
) =>
|
||||
DragAndDrop(
|
||||
targets: _strings(targets),
|
||||
items: [
|
||||
for (final item in items)
|
||||
switch (item) {
|
||||
{'text': final String text, 'target': final int target} => DragItem(
|
||||
text: text,
|
||||
target: target,
|
||||
),
|
||||
_ => throw const FormatException('A malformed item.'),
|
||||
},
|
||||
],
|
||||
),
|
||||
(
|
||||
StepKind.fillInTheBlank,
|
||||
{'passage': final String passage, 'blanks': final List<Object?> blanks},
|
||||
) =>
|
||||
FillInTheBlank(passage: passage, blanks: _strings(blanks)),
|
||||
_ => throw FormatException('Not a $kind exercise.'),
|
||||
};
|
||||
|
||||
/// [answer] as the answer log stores it: its kind's JSON.
|
||||
String encodeAnswer(ExerciseAnswer answer) => jsonEncode(switch (answer) {
|
||||
SingleChoiceAnswer(:final selected) => {'selected': selected},
|
||||
MultipleChoiceAnswer(:final selected) => {
|
||||
'selected': [...selected]..sort(),
|
||||
},
|
||||
DragAndDropAnswer(:final placements) => {
|
||||
'placements': {
|
||||
for (final MapEntry(key: item, value: target) in placements.entries)
|
||||
'$item': target,
|
||||
},
|
||||
},
|
||||
FillInTheBlankAnswer(:final words) => {'words': words},
|
||||
});
|
||||
|
||||
/// The answer to a step of [kind] in [text], as [encodeAnswer] wrote it; a
|
||||
/// [FormatException] for anything else.
|
||||
ExerciseAnswer decodeAnswer(StepKind kind, String text) =>
|
||||
switch ((kind, jsonDecode(text))) {
|
||||
(StepKind.singleChoice, {'selected': final int selected}) =>
|
||||
SingleChoiceAnswer(selected),
|
||||
(StepKind.multipleChoice, {'selected': final List<Object?> selected}) =>
|
||||
MultipleChoiceAnswer(_ints(selected).toSet()),
|
||||
(
|
||||
StepKind.dragAndDrop,
|
||||
{'placements': final Map<String, Object?> placements},
|
||||
) =>
|
||||
DragAndDropAnswer({
|
||||
for (final MapEntry(key: item, value: target) in placements.entries)
|
||||
int.parse(item): switch (target) {
|
||||
final int target => target,
|
||||
_ => throw const FormatException('A malformed placement.'),
|
||||
},
|
||||
}),
|
||||
(StepKind.fillInTheBlank, {'words': final List<Object?> words}) =>
|
||||
FillInTheBlankAnswer([
|
||||
for (final word in words)
|
||||
switch (word) {
|
||||
null => null,
|
||||
final String word => word,
|
||||
_ => throw const FormatException('A malformed word.'),
|
||||
},
|
||||
]),
|
||||
_ => throw FormatException('Not an answer to a $kind step.'),
|
||||
};
|
||||
|
||||
StepSource _source(Object? json) => switch (json) {
|
||||
{'title': final String title} => StepSource(
|
||||
title: title,
|
||||
authors: _optional(json, 'authors'),
|
||||
year: _optional(json, 'year'),
|
||||
url: _optional(json, 'url'),
|
||||
),
|
||||
_ => throw const FormatException('A malformed source.'),
|
||||
};
|
||||
|
||||
String _optional(Map<Object?, Object?> json, String key) => switch (json[key]) {
|
||||
null => '',
|
||||
final String value => value,
|
||||
_ => throw FormatException('A source with a malformed $key.'),
|
||||
};
|
||||
|
||||
List<String> _strings(List<Object?> json) => [
|
||||
for (final value in json)
|
||||
switch (value) {
|
||||
final String text => text,
|
||||
_ => throw const FormatException('Text expected.'),
|
||||
},
|
||||
];
|
||||
|
||||
List<int> _ints(List<Object?> json) => [
|
||||
for (final value in json)
|
||||
switch (value) {
|
||||
final int number => number,
|
||||
_ => throw const FormatException('A number expected.'),
|
||||
},
|
||||
];
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/// An answer's row as the device's database stores it.
|
||||
typedef AnswerRow = ({
|
||||
String id,
|
||||
String stepId,
|
||||
DateTime answeredAt,
|
||||
String outcome,
|
||||
String? answer,
|
||||
});
|
||||
|
||||
/// The boundary to the answer log of learning paths in this person's local,
|
||||
/// synced database. Tests replace it with a fake.
|
||||
abstract interface class PathAnswerGateway() {
|
||||
/// The answers to the steps [stepIds], unordered; every answer without.
|
||||
Future<List<AnswerRow>> answers({Set<String>? stepIds});
|
||||
|
||||
/// Appends [answers]; the ids given are ignored, each row gets its own.
|
||||
Future<void> insert(
|
||||
List<({String stepId, DateTime answeredAt, String outcome, String? answer})>
|
||||
answers,
|
||||
);
|
||||
|
||||
/// Deletes the fresh starts of the steps [stepIds] made at [at], to undo
|
||||
/// them.
|
||||
Future<void> deleteResets(Set<String> stepIds, DateTime at);
|
||||
|
||||
/// Fires when a sync brought changes from the person's other devices.
|
||||
Stream<void> get remoteChanges;
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_step_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_answer_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/answer.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/progress.dart';
|
||||
|
||||
/// Where the person stands in their learning paths, worked out from an
|
||||
/// append-only log of answers (decided 2026-10-02), as a card's schedule is
|
||||
/// from its reviews: answers from different devices never conflict.
|
||||
class PathProgressRepository(final PathAnswerGateway _gateway) {
|
||||
this {
|
||||
_remoteChanges = _gateway.remoteChanges.listen((_) => _changes.add(null));
|
||||
}
|
||||
|
||||
final _changes = StreamController<void>.broadcast();
|
||||
late final StreamSubscription<void> _remoteChanges;
|
||||
|
||||
/// Fires after every answer, written here or synced from elsewhere.
|
||||
Stream<void> get changes => _changes.stream;
|
||||
|
||||
/// The progress of the steps in [kinds] (their kinds, by step id), by
|
||||
/// step id; a step without answers is left out. An answer this app can't
|
||||
/// read still counts, without being shown again.
|
||||
Future<Map<String, StepProgress>> of(Map<String, StepKind> kinds) async =>
|
||||
progressOf([
|
||||
for (final row in await _gateway.answers(stepIds: kinds.keys.toSet()))
|
||||
?_decode(row, kinds[row.stepId]),
|
||||
]);
|
||||
|
||||
/// Records an answer to the step [stepId], made now.
|
||||
Future<void> record(
|
||||
String stepId,
|
||||
AnswerOutcome outcome, [
|
||||
ExerciseAnswer? answer,
|
||||
]) async {
|
||||
await _gateway.insert([
|
||||
(
|
||||
stepId: stepId,
|
||||
answeredAt: clock.now().toUtc(),
|
||||
outcome: outcome.value,
|
||||
answer: answer == null ? null : encodeAnswer(answer),
|
||||
),
|
||||
]);
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// Starts the steps [stepIds] afresh, now; returns when, for
|
||||
/// [undoReset].
|
||||
Future<DateTime> reset(Set<String> stepIds) async {
|
||||
final at = clock.now().toUtc();
|
||||
await _gateway.insert([
|
||||
for (final stepId in stepIds)
|
||||
(
|
||||
stepId: stepId,
|
||||
answeredAt: at,
|
||||
outcome: AnswerOutcome.reset.value,
|
||||
answer: null,
|
||||
),
|
||||
]);
|
||||
_changes.add(null);
|
||||
return at;
|
||||
}
|
||||
|
||||
/// Undoes [reset] of [stepIds] at [at].
|
||||
Future<void> undoReset(Set<String> stepIds, DateTime at) async {
|
||||
await _gateway.deleteResets(stepIds, at);
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// When steps were done (answered right, or passed), in UTC and in time
|
||||
/// order, for the dashboard's activity.
|
||||
Future<List<DateTime>> doneTimes() async => [
|
||||
for (final row in await _gateway.answers())
|
||||
if (row.outcome == AnswerOutcome.right.value ||
|
||||
row.outcome == AnswerOutcome.continued.value)
|
||||
row.answeredAt,
|
||||
]..sort();
|
||||
|
||||
/// Stops following remote changes.
|
||||
Future<void> dispose() async {
|
||||
await _remoteChanges.cancel();
|
||||
await _changes.close();
|
||||
}
|
||||
|
||||
StepAnswer? _decode(AnswerRow row, StepKind? kind) {
|
||||
final outcome = AnswerOutcome.of(row.outcome);
|
||||
if (outcome == null || kind == null) {
|
||||
return null;
|
||||
}
|
||||
ExerciseAnswer? answer;
|
||||
if (row.answer case final text?) {
|
||||
try {
|
||||
answer = decodeAnswer(kind, text);
|
||||
} on FormatException catch (error) {
|
||||
log('Unread answer ${row.id}: ${error.message}', name: 'Paths');
|
||||
}
|
||||
}
|
||||
return StepAnswer(
|
||||
id: row.id,
|
||||
stepId: row.stepId,
|
||||
at: row.answeredAt,
|
||||
outcome: outcome,
|
||||
answer: answer,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// [LearningPathGateway] on the person's local database, which the sync
|
||||
/// engine keeps in step with their other devices. Step revisions record
|
||||
/// [_authorId] as their author.
|
||||
class SyncLearningPathGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
final String _authorId,
|
||||
) implements LearningPathGateway {
|
||||
@override
|
||||
Future<List<PathRow>> paths({String? studysetId}) async => [
|
||||
for (final path in await protocol.LearningPath.db.find(
|
||||
_db,
|
||||
where: studysetId == null
|
||||
? null
|
||||
: (t) => t.studysetId.equals(_uuid(studysetId)),
|
||||
))
|
||||
(
|
||||
id: path.id!.uuid,
|
||||
studysetId: path.studysetId.uuid,
|
||||
position: path.position,
|
||||
payload: path.payload,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<String> insertPath({
|
||||
required String studysetId,
|
||||
required String position,
|
||||
required ByteData payload,
|
||||
}) async {
|
||||
final path = await protocol.LearningPath.db.insertRow(
|
||||
_db,
|
||||
protocol.LearningPath(
|
||||
studysetId: _uuid(studysetId),
|
||||
position: position,
|
||||
payload: payload,
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
return path.id!.uuid;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updatePathPayload(String id, ByteData payload) async {
|
||||
if (await protocol.LearningPath.db.findById(_db, _uuid(id))
|
||||
case final path?) {
|
||||
// Only the named column changes (ADR 0001, rule 1).
|
||||
await protocol.LearningPath.db.updateRow(
|
||||
_db,
|
||||
path.copyWith(payload: payload),
|
||||
columns: (t) => [t.payload],
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updatePathPosition(String id, String position) async {
|
||||
if (await protocol.LearningPath.db.findById(_db, _uuid(id))
|
||||
case final path?) {
|
||||
await protocol.LearningPath.db.updateRow(
|
||||
_db,
|
||||
path.copyWith(position: position),
|
||||
columns: (t) => [t.position],
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deletePath(String id) async {
|
||||
await protocol.LearningPathTag.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.pathId.equals(_uuid(id)),
|
||||
);
|
||||
await protocol.LearningPath.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.id.equals(_uuid(id)),
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<StepRow>> steps(Set<String> pathIds) async {
|
||||
if (pathIds.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
return [
|
||||
for (final step in await protocol.LearningPathStep.db.find(
|
||||
_db,
|
||||
where: (t) => t.pathId.inSet({for (final id in pathIds) _uuid(id)}),
|
||||
))
|
||||
(
|
||||
id: step.id!.uuid,
|
||||
pathId: step.pathId.uuid,
|
||||
position: step.position,
|
||||
kind: step.kind,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<StepRevisionRow>> revisions(Set<String> stepIds) async {
|
||||
if (stepIds.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
return [
|
||||
for (final revision in await protocol.LearningPathStepRevision.db.find(
|
||||
_db,
|
||||
where: (t) => t.stepId.inSet({for (final id in stepIds) _uuid(id)}),
|
||||
))
|
||||
(
|
||||
id: revision.id!.uuid,
|
||||
stepId: revision.stepId.uuid,
|
||||
parents: {for (final parent in revision.parents) parent.uuid},
|
||||
payload: revision.payload,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<({String stepId, String revisionId})> insertStep({
|
||||
required String pathId,
|
||||
required String position,
|
||||
required String kind,
|
||||
required ByteData payload,
|
||||
}) async {
|
||||
final step = await protocol.LearningPathStep.db.insertRow(
|
||||
_db,
|
||||
protocol.LearningPathStep(
|
||||
pathId: _uuid(pathId),
|
||||
position: position,
|
||||
kind: kind,
|
||||
),
|
||||
);
|
||||
final revisionId = await insertRevision(
|
||||
stepId: step.id!.uuid,
|
||||
parents: const {},
|
||||
payload: payload,
|
||||
);
|
||||
return (stepId: step.id!.uuid, revisionId: revisionId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> insertRevision({
|
||||
required String stepId,
|
||||
required Set<String> parents,
|
||||
required ByteData payload,
|
||||
}) async {
|
||||
final revision = await protocol.LearningPathStepRevision.db.insertRow(
|
||||
_db,
|
||||
protocol.LearningPathStepRevision(
|
||||
stepId: _uuid(stepId),
|
||||
parents: [for (final parent in parents) _uuid(parent)],
|
||||
authorId: _uuid(_authorId),
|
||||
payload: payload,
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
return revision.id!.uuid;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateStepPosition(String id, String position) async {
|
||||
if (await protocol.LearningPathStep.db.findById(_db, _uuid(id))
|
||||
case final step?) {
|
||||
await protocol.LearningPathStep.db.updateRow(
|
||||
_db,
|
||||
step.copyWith(position: position),
|
||||
columns: (t) => [t.position],
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> blank({String? stepId, String? pathId}) async {
|
||||
if (stepId != null) {
|
||||
final revisions = await protocol.LearningPathStepRevision.db.find(
|
||||
_db,
|
||||
where: (t) => t.stepId.equals(_uuid(stepId)),
|
||||
);
|
||||
// Column-scoped updates, which sync like any other edit (ADR 0002,
|
||||
// rule 2).
|
||||
await protocol.LearningPathStepRevision.db.update(
|
||||
_db,
|
||||
[
|
||||
for (final revision in revisions)
|
||||
revision.copyWith(payload: redactedPayload),
|
||||
],
|
||||
columns: (t) => [t.payload],
|
||||
);
|
||||
}
|
||||
if (pathId != null) {
|
||||
await updatePathPayload(pathId, redactedPayload);
|
||||
}
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteStep(String id) async {
|
||||
final step = _uuid(id);
|
||||
// Its answers go too: no content, but personal study data that would
|
||||
// otherwise stay on the server as hidden rows.
|
||||
await protocol.LearningPathAnswer.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.stepId.equals(step),
|
||||
);
|
||||
await protocol.LearningPathStepRevision.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.stepId.equals(step),
|
||||
);
|
||||
await protocol.LearningPathStep.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.id.equals(step),
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _sync.merges;
|
||||
|
||||
protocol.UuidValue _uuid(String id) => protocol.UuidValue.withValidation(id);
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_answer_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// [PathAnswerGateway] on the person's local database, which the sync
|
||||
/// engine keeps in step with their other devices.
|
||||
class SyncPathAnswerGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
) implements PathAnswerGateway {
|
||||
@override
|
||||
Future<List<AnswerRow>> answers({Set<String>? stepIds}) async {
|
||||
if (stepIds != null && stepIds.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
return [
|
||||
for (final answer in await protocol.LearningPathAnswer.db.find(
|
||||
_db,
|
||||
where: stepIds == null
|
||||
? null
|
||||
: (t) => t.stepId.inSet({
|
||||
for (final id in stepIds) protocol.UuidValue.withValidation(id),
|
||||
}),
|
||||
))
|
||||
(
|
||||
id: answer.id!.uuid,
|
||||
stepId: answer.stepId.uuid,
|
||||
answeredAt: answer.answeredAt,
|
||||
outcome: answer.outcome,
|
||||
answer: answer.answer,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> insert(
|
||||
List<({String stepId, DateTime answeredAt, String outcome, String? answer})>
|
||||
answers,
|
||||
) async {
|
||||
if (answers.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await protocol.LearningPathAnswer.db.insert(_db, [
|
||||
for (final answer in answers)
|
||||
protocol.LearningPathAnswer(
|
||||
stepId: protocol.UuidValue.withValidation(answer.stepId),
|
||||
answeredAt: answer.answeredAt,
|
||||
outcome: answer.outcome,
|
||||
answer: answer.answer,
|
||||
),
|
||||
]);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteResets(Set<String> stepIds, DateTime at) async {
|
||||
if (stepIds.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await protocol.LearningPathAnswer.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) =>
|
||||
t.stepId.inSet({
|
||||
for (final id in stepIds) protocol.UuidValue.withValidation(id),
|
||||
}) &
|
||||
t.outcome.equals('reset') &
|
||||
t.answeredAt.equals(at),
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _sync.merges;
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
|
||||
/// What a person answered to an exercise.
|
||||
sealed class const ExerciseAnswer() with Equatable;
|
||||
|
||||
/// The option chosen of a [SingleChoice].
|
||||
final class const SingleChoiceAnswer(
|
||||
/// Its index.
|
||||
final int selected,
|
||||
) extends ExerciseAnswer {
|
||||
@override
|
||||
List<Object?> get props => [selected];
|
||||
}
|
||||
|
||||
/// The options chosen of a [MultipleChoice].
|
||||
final class const MultipleChoiceAnswer(
|
||||
/// Their indexes.
|
||||
final Set<int> selected,
|
||||
) extends ExerciseAnswer {
|
||||
@override
|
||||
List<Object?> get props => [selected];
|
||||
}
|
||||
|
||||
/// Where the items of a [DragAndDrop] were dropped.
|
||||
final class const DragAndDropAnswer(
|
||||
/// The category each placed item went to, by the item's index.
|
||||
final Map<int, int> placements,
|
||||
) extends ExerciseAnswer {
|
||||
@override
|
||||
List<Object?> get props => [placements];
|
||||
}
|
||||
|
||||
/// The words dropped into the gaps of a [FillInTheBlank].
|
||||
final class const FillInTheBlankAnswer(
|
||||
/// The word in each gap, in order; null for a gap left empty.
|
||||
final List<String?> words,
|
||||
) extends ExerciseAnswer {
|
||||
@override
|
||||
List<Object?> get props => [words];
|
||||
}
|
||||
|
||||
/// How an answer fared: whether it is right, and which of the person's own
|
||||
/// picks were wrong, so only those show as wrong; what they didn't pick is
|
||||
/// never revealed (decided 2026-10-02).
|
||||
final class const Grade({
|
||||
/// Whether the answer is right, as a whole.
|
||||
required final bool right,
|
||||
|
||||
/// The picks that were wrong: option indexes for choices, item indexes for
|
||||
/// dragging, gap indexes for blanks.
|
||||
final Set<int> wrongPicks = const {},
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [right, wrongPicks];
|
||||
}
|
||||
|
||||
/// How [answer] fares against [exercise]; an answer of another kind is
|
||||
/// wrong.
|
||||
Grade grade(Exercise exercise, ExerciseAnswer answer) => switch ((
|
||||
exercise,
|
||||
answer,
|
||||
)) {
|
||||
(SingleChoice(:final correct), SingleChoiceAnswer(:final selected)) =>
|
||||
selected == correct
|
||||
? const Grade(right: true)
|
||||
: Grade(right: false, wrongPicks: {selected}),
|
||||
(
|
||||
MultipleChoice(:final correct),
|
||||
MultipleChoiceAnswer(:final selected),
|
||||
) =>
|
||||
Grade(
|
||||
right: selected.length == correct.length && selected.containsAll(correct),
|
||||
wrongPicks: selected.difference(correct),
|
||||
),
|
||||
(DragAndDrop(:final items), DragAndDropAnswer(:final placements)) => () {
|
||||
final wrong = {
|
||||
for (final MapEntry(key: item, value: target) in placements.entries)
|
||||
if (item >= items.length || items[item].target != target) item,
|
||||
};
|
||||
return Grade(
|
||||
right: wrong.isEmpty && placements.length == items.length,
|
||||
wrongPicks: wrong,
|
||||
);
|
||||
}(),
|
||||
(FillInTheBlank(:final blanks), FillInTheBlankAnswer(:final words)) => () {
|
||||
final wrong = {
|
||||
for (final (gap, word) in words.indexed)
|
||||
if (word != null && (gap >= blanks.length || blanks[gap] != word)) gap,
|
||||
};
|
||||
return Grade(
|
||||
right:
|
||||
wrong.isEmpty &&
|
||||
words.length == blanks.length &&
|
||||
words.every((word) => word != null),
|
||||
wrongPicks: wrong,
|
||||
);
|
||||
}(),
|
||||
_ => const Grade(right: false),
|
||||
};
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// What kind of step a path's step is (decided 2026-10-02); fixed once the
|
||||
/// step is created.
|
||||
enum StepKind(
|
||||
/// The value the step's row stores.
|
||||
final String value,
|
||||
) {
|
||||
/// Something to read; passed by going on.
|
||||
info('info'),
|
||||
|
||||
/// One right option among several.
|
||||
singleChoice('singleChoice'),
|
||||
|
||||
/// Every right option among several.
|
||||
multipleChoice('multipleChoice'),
|
||||
|
||||
/// Items to drag into the categories they belong to.
|
||||
dragAndDrop('dragAndDrop'),
|
||||
|
||||
/// Words to drag into the gaps of a passage.
|
||||
fillInTheBlank('fillInTheBlank');
|
||||
|
||||
/// The kind stored as [value], or null for one this app doesn't know.
|
||||
static StepKind? of(String value) =>
|
||||
values.where((kind) => kind.value == value).firstOrNull;
|
||||
|
||||
/// Whether it is answered rather than read.
|
||||
bool get isExercise => this != info;
|
||||
|
||||
/// A new exercise of this kind, with nothing in it yet; none for [info].
|
||||
Exercise? get empty => switch (this) {
|
||||
info => null,
|
||||
singleChoice => const SingleChoice(options: ['', '']),
|
||||
multipleChoice => const MultipleChoice(options: ['', '']),
|
||||
dragAndDrop => const DragAndDrop(targets: ['', '']),
|
||||
fillInTheBlank => const FillInTheBlank(),
|
||||
};
|
||||
}
|
||||
|
||||
/// The marker of a gap in a fill-in-the-blank passage: three underscores.
|
||||
const gapMarker = '___';
|
||||
|
||||
/// What an exercise step asks.
|
||||
sealed class const Exercise() with Equatable {
|
||||
/// Whether it can be answered: everything it needs is there.
|
||||
bool get isComplete;
|
||||
}
|
||||
|
||||
/// One right option among several.
|
||||
final class const SingleChoice({
|
||||
/// The options, as shown.
|
||||
final List<String> options = const [],
|
||||
|
||||
/// The index of the right one; null until chosen.
|
||||
final int? correct,
|
||||
}) extends Exercise {
|
||||
@override
|
||||
bool get isComplete =>
|
||||
options.length >= 2 &&
|
||||
options.every((option) => option.trim().isNotEmpty) &&
|
||||
correct != null &&
|
||||
correct! < options.length;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [options, correct];
|
||||
}
|
||||
|
||||
/// Every right option among several; at least one is right.
|
||||
final class const MultipleChoice({
|
||||
/// The options, as shown.
|
||||
final List<String> options = const [],
|
||||
|
||||
/// The indexes of the right ones.
|
||||
final Set<int> correct = const {},
|
||||
}) extends Exercise {
|
||||
@override
|
||||
bool get isComplete =>
|
||||
options.length >= 2 &&
|
||||
options.every((option) => option.trim().isNotEmpty) &&
|
||||
correct.isNotEmpty &&
|
||||
correct.every((index) => index < options.length);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [options, correct];
|
||||
}
|
||||
|
||||
/// An item to drag and the category it belongs to.
|
||||
final class const DragItem({
|
||||
/// The item, as shown.
|
||||
required final String text,
|
||||
|
||||
/// The index of its category.
|
||||
required final int target,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [text, target];
|
||||
}
|
||||
|
||||
/// Items to drag into the categories they belong to; several may share one.
|
||||
final class const DragAndDrop({
|
||||
/// The categories, as shown.
|
||||
final List<String> targets = const [],
|
||||
|
||||
/// The items, each belonging to one category.
|
||||
final List<DragItem> items = const [],
|
||||
}) extends Exercise {
|
||||
@override
|
||||
bool get isComplete =>
|
||||
targets.length >= 2 &&
|
||||
targets.every((target) => target.trim().isNotEmpty) &&
|
||||
items.isNotEmpty &&
|
||||
items.every(
|
||||
(item) => item.text.trim().isNotEmpty && item.target < targets.length,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [targets, items];
|
||||
}
|
||||
|
||||
/// A passage with gaps ([gapMarker]) and the word belonging in each, in
|
||||
/// order; the words are the pool to drag from.
|
||||
final class const FillInTheBlank({
|
||||
/// The passage, a [gapMarker] for each gap.
|
||||
final String passage = '',
|
||||
|
||||
/// The word for each gap, in the order of the gaps.
|
||||
final List<String> blanks = const [],
|
||||
}) extends Exercise {
|
||||
/// How many gaps the passage has.
|
||||
int get gaps => gapMarker.allMatches(passage).length;
|
||||
|
||||
/// The passage's text around the gaps: one more part than [gaps].
|
||||
List<String> get parts => passage.split(gapMarker);
|
||||
|
||||
@override
|
||||
bool get isComplete =>
|
||||
gaps > 0 &&
|
||||
blanks.length == gaps &&
|
||||
blanks.every((blank) => blank.trim().isNotEmpty);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [passage, blanks];
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
|
||||
/// What a learning path says about itself: its title, a secondary title (a
|
||||
/// translation, say) and a description.
|
||||
final class const LearningPathContent({
|
||||
/// The title; never blank once saved.
|
||||
required final String title,
|
||||
|
||||
/// A secondary title, e.g. the title in the language learned; null without.
|
||||
final String? secondaryTitle,
|
||||
|
||||
/// What the path is about, as rich text.
|
||||
required final RichContent description,
|
||||
}) with Equatable {
|
||||
/// A path called [title], with nothing else yet.
|
||||
factory titled(String title) =>
|
||||
LearningPathContent(title: title, description: RichContent.empty());
|
||||
|
||||
/// This content as it is stored: titles without surrounding spaces, a
|
||||
/// blank secondary title none.
|
||||
LearningPathContent get normalized {
|
||||
final secondary = secondaryTitle?.trim();
|
||||
return LearningPathContent(
|
||||
title: title.trim(),
|
||||
secondaryTitle: secondary == null || secondary.isEmpty ? null : secondary,
|
||||
description: description,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [title, secondaryTitle, description];
|
||||
}
|
||||
|
||||
/// A learning path: one lesson of a studyset, an ordered sequence of steps.
|
||||
final class const LearningPath({
|
||||
/// A UUID v7, made on the device that created it.
|
||||
required final String id,
|
||||
|
||||
/// The studyset it belongs to.
|
||||
required final String studysetId,
|
||||
|
||||
/// Where it stands among the studyset's paths: a fractional index.
|
||||
required final String position,
|
||||
|
||||
/// What it says about itself.
|
||||
required final LearningPathContent content,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [id, studysetId, position, content];
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
|
||||
|
||||
/// One version of a step's content, never changed once written (ADR 0001,
|
||||
/// rule 2).
|
||||
final class const StepRevision({
|
||||
@override required final String id,
|
||||
@override required final Set<String> parents,
|
||||
@override required final StepContent content,
|
||||
}) with Equatable implements Revision<StepContent> {
|
||||
@override
|
||||
List<Object?> get props => [id, parents, content];
|
||||
}
|
||||
|
||||
/// A step's content, merged from its revisions by [mergeStepRevisions].
|
||||
typedef MergedStep = Merged<StepField, StepContent>;
|
||||
|
||||
/// Merges a step's [revisions] field by field, or null without any: see
|
||||
/// [mergeRevisionsOf].
|
||||
MergedStep? mergeStepRevisions(Iterable<StepRevision> revisions) =>
|
||||
mergeRevisionsOf(revisions, fields: StepField.values);
|
||||
|
||||
/// A step of a learning path as the app shows it: its revisions merged.
|
||||
final class const LearningPathStep({
|
||||
/// A UUID v7, made on the device that created it.
|
||||
required final String id,
|
||||
|
||||
/// Where it stands in its path: a fractional index.
|
||||
required final String position,
|
||||
|
||||
/// What kind of step it is; fixed.
|
||||
required final StepKind kind,
|
||||
|
||||
/// The current content. For a field in [conflicts], the newest version
|
||||
/// shows until the person chooses.
|
||||
required final StepContent content,
|
||||
|
||||
/// The revisions its content is merged from: all current heads.
|
||||
required final Set<String> heads,
|
||||
|
||||
/// The fields changed differently on different devices, with every
|
||||
/// version, oldest first.
|
||||
final Map<StepField, List<Object?>> conflicts = const {},
|
||||
}) with Equatable {
|
||||
/// Whether devices changed a field differently and the person has to
|
||||
/// choose.
|
||||
bool get hasConflict => conflicts.isNotEmpty;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, position, kind, content, heads, conflicts];
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/answer.dart';
|
||||
|
||||
/// How an answer to a step came out (decided 2026-10-02).
|
||||
enum AnswerOutcome(
|
||||
/// The value the answer log stores.
|
||||
final String value,
|
||||
) {
|
||||
/// An exercise answered right.
|
||||
right('right'),
|
||||
|
||||
/// An exercise answered wrong.
|
||||
wrong('wrong'),
|
||||
|
||||
/// An info step read and passed.
|
||||
continued('continued'),
|
||||
|
||||
/// No answer but a fresh start: the step's progress counts only what
|
||||
/// follows.
|
||||
reset('reset');
|
||||
|
||||
/// The outcome stored as [value], or null for one this app doesn't know.
|
||||
static AnswerOutcome? of(String value) =>
|
||||
values.where((outcome) => outcome.value == value).firstOrNull;
|
||||
}
|
||||
|
||||
/// One entry of the answer log: a step answered, never changed once written.
|
||||
final class const StepAnswer({
|
||||
/// A UUID v7, made on the device that wrote it.
|
||||
required final String id,
|
||||
|
||||
/// The step answered.
|
||||
required final String stepId,
|
||||
|
||||
/// When, in UTC.
|
||||
required final DateTime at,
|
||||
|
||||
/// How it came out.
|
||||
required final AnswerOutcome outcome,
|
||||
|
||||
/// What was answered; null for an info step and a fresh start.
|
||||
final ExerciseAnswer? answer,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [id, stepId, at, outcome, answer];
|
||||
}
|
||||
|
||||
/// Where a step stands for the person, worked out from its [StepAnswer]s.
|
||||
final class const StepProgress({
|
||||
/// Whether it is done: answered right (an info step: passed) since its
|
||||
/// last fresh start.
|
||||
final bool done = false,
|
||||
|
||||
/// The right answer that did it, to show again; null otherwise.
|
||||
final ExerciseAnswer? answer,
|
||||
|
||||
/// When it was done; null while it isn't.
|
||||
final DateTime? doneAt,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [done, answer, doneAt];
|
||||
}
|
||||
|
||||
/// The progress of each step in [answers], by step id: only what came after
|
||||
/// a step's last fresh start counts.
|
||||
Map<String, StepProgress> progressOf(Iterable<StepAnswer> answers) {
|
||||
final byStep = <String, List<StepAnswer>>{};
|
||||
for (final answer in answers) {
|
||||
(byStep[answer.stepId] ??= []).add(answer);
|
||||
}
|
||||
return {
|
||||
for (final MapEntry(key: stepId, value: log) in byStep.entries)
|
||||
stepId: _progress(log..sort(_inTimeOrder)),
|
||||
};
|
||||
}
|
||||
|
||||
StepProgress _progress(List<StepAnswer> log) {
|
||||
StepAnswer? done;
|
||||
for (final answer in log) {
|
||||
switch (answer.outcome) {
|
||||
case AnswerOutcome.reset:
|
||||
done = null;
|
||||
case AnswerOutcome.right || AnswerOutcome.continued:
|
||||
done ??= answer;
|
||||
case AnswerOutcome.wrong:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return done == null
|
||||
? const StepProgress()
|
||||
: StepProgress(done: true, answer: done.answer, doneAt: done.at);
|
||||
}
|
||||
|
||||
/// Earlier first; ties by id, which sorts by creation.
|
||||
int _inTimeOrder(StepAnswer a, StepAnswer b) => switch (a.at.compareTo(b.at)) {
|
||||
0 => a.id.compareTo(b.id),
|
||||
final order => order,
|
||||
};
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
|
||||
/// A work a step draws on, as a citation shows it.
|
||||
final class const StepSource({
|
||||
/// Who wrote it, as they are cited, e.g. "Tae Kim".
|
||||
final String authors = '',
|
||||
|
||||
/// When it appeared, e.g. "2014"; empty when unknown.
|
||||
final String year = '',
|
||||
|
||||
/// Its title.
|
||||
required final String title,
|
||||
|
||||
/// Where to find it; empty without.
|
||||
final String url = '',
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [authors, year, title, url];
|
||||
}
|
||||
|
||||
/// The parts of a step that merge separately when devices edited it while
|
||||
/// offline (ADR 0003).
|
||||
enum StepField() {
|
||||
/// The title.
|
||||
title,
|
||||
|
||||
/// The text, read before the exercise or on its own.
|
||||
body,
|
||||
|
||||
/// The exercise; none for an info step.
|
||||
exercise,
|
||||
|
||||
/// Shown after a right answer.
|
||||
rightFeedback,
|
||||
|
||||
/// Shown after a wrong answer.
|
||||
wrongFeedback,
|
||||
|
||||
/// The works the step draws on.
|
||||
sources,
|
||||
}
|
||||
|
||||
/// What a step of a learning path holds.
|
||||
final class const StepContent({
|
||||
/// The title; empty for an untitled step.
|
||||
final String title = '',
|
||||
|
||||
/// The text.
|
||||
required final RichContent body,
|
||||
|
||||
/// The exercise, of the step's kind; null for an info step.
|
||||
final Exercise? exercise,
|
||||
|
||||
/// Shown after a right answer; empty for none.
|
||||
required final RichContent rightFeedback,
|
||||
|
||||
/// Shown after a wrong answer; empty for none.
|
||||
required final RichContent wrongFeedback,
|
||||
|
||||
/// The works the step draws on.
|
||||
final List<StepSource> sources = const [],
|
||||
}) with Equatable implements MergeableContent<StepField, StepContent> {
|
||||
/// A new step of [kind], with nothing written yet.
|
||||
factory empty(StepKind kind) => StepContent(
|
||||
body: RichContent.empty(),
|
||||
exercise: kind.empty,
|
||||
rightFeedback: RichContent.empty(),
|
||||
wrongFeedback: RichContent.empty(),
|
||||
);
|
||||
|
||||
@override
|
||||
Object? operator [](StepField field) => switch (field) {
|
||||
StepField.title => title,
|
||||
StepField.body => body,
|
||||
StepField.exercise => exercise,
|
||||
StepField.rightFeedback => rightFeedback,
|
||||
StepField.wrongFeedback => wrongFeedback,
|
||||
StepField.sources => sources,
|
||||
};
|
||||
|
||||
@override
|
||||
StepContent withField(StepField field, Object? value) => StepContent(
|
||||
title: field == StepField.title ? value! as String : title,
|
||||
body: field == StepField.body ? value! as RichContent : body,
|
||||
exercise: field == StepField.exercise ? value as Exercise? : exercise,
|
||||
rightFeedback: field == StepField.rightFeedback
|
||||
? value! as RichContent
|
||||
: rightFeedback,
|
||||
wrongFeedback: field == StepField.wrongFeedback
|
||||
? value! as RichContent
|
||||
: wrongFeedback,
|
||||
sources: field == StepField.sources ? value! as List<StepSource> : sources,
|
||||
);
|
||||
|
||||
/// This content as it is stored: the title without surrounding spaces.
|
||||
StepContent get normalized => withField(StepField.title, title.trim());
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
title,
|
||||
body,
|
||||
exercise,
|
||||
rightFeedback,
|
||||
wrongFeedback,
|
||||
sources,
|
||||
];
|
||||
}
|
||||
|
|
@ -1,21 +1,24 @@
|
|||
import 'package:halkyon_learn_flutter/src/core/async/delayed_deletions.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/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
|
||||
/// The person's studyset deletions, which can be undone for a moment (see
|
||||
/// [DelayedDeletions]). Once the time to undo is over, the studysets of the
|
||||
/// deleted one's subtree are blanked and deleted with their cards and notes.
|
||||
/// deleted one's subtree are blanked and deleted with their cards, notes and
|
||||
/// learning paths.
|
||||
class StudysetDeletions(
|
||||
StudysetRepository studysets,
|
||||
CardRepository cards,
|
||||
NoteRepository notes, {
|
||||
NoteRepository notes,
|
||||
LearningPathRepository paths, {
|
||||
super.undoWindow,
|
||||
}) extends DelayedDeletions {
|
||||
this
|
||||
: super(
|
||||
(id) => _deleteSubtree(id, studysets, cards, notes),
|
||||
(id) => _deleteSubtree(id, studysets, cards, notes, paths),
|
||||
logName: 'Studysets',
|
||||
);
|
||||
}
|
||||
|
|
@ -25,13 +28,14 @@ Future<void> _deleteSubtree(
|
|||
StudysetRepository studysets,
|
||||
CardRepository cards,
|
||||
NoteRepository notes,
|
||||
LearningPathRepository paths,
|
||||
) async {
|
||||
// What is inside first, so no studyset is ever left without its parent;
|
||||
// each studyset's cards and notes before it, so no content outlives its
|
||||
// studyset.
|
||||
// each studyset's content before it, so none outlives its studyset.
|
||||
for (final studysetId in subtreeOf(id, await studysets.all()).reversed) {
|
||||
await cards.deleteAllOf(studysetId);
|
||||
await notes.deleteAllOf(studysetId);
|
||||
await paths.deleteAllOf(studysetId);
|
||||
await studysets.delete(studysetId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,16 @@ class SyncTagGateway(
|
|||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<PathTagRow>> pathTags() async => [
|
||||
for (final pathTag in await protocol.LearningPathTag.db.find(_db))
|
||||
(
|
||||
id: pathTag.id!.uuid,
|
||||
pathId: pathTag.pathId.uuid,
|
||||
tagId: pathTag.tagId.uuid,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<String> insertTag(ByteData payload) async {
|
||||
final tag = await protocol.Tag.db.insertRow(
|
||||
|
|
@ -72,6 +82,10 @@ class SyncTagGateway(
|
|||
_db,
|
||||
where: (t) => t.tagId.equals(_uuid(id)),
|
||||
);
|
||||
await protocol.LearningPathTag.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.tagId.equals(_uuid(id)),
|
||||
);
|
||||
await protocol.Tag.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.id.equals(_uuid(id)),
|
||||
|
|
@ -139,6 +153,36 @@ class SyncTagGateway(
|
|||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> insertPathTags(
|
||||
Iterable<({String pathId, String tagId})> assignments,
|
||||
) async {
|
||||
final rows = [
|
||||
for (final assignment in assignments)
|
||||
protocol.LearningPathTag(
|
||||
pathId: _uuid(assignment.pathId),
|
||||
tagId: _uuid(assignment.tagId),
|
||||
),
|
||||
];
|
||||
if (rows.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await protocol.LearningPathTag.db.insert(_db, rows);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deletePathTags(Set<String> ids) async {
|
||||
if (ids.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await protocol.LearningPathTag.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.id.inSet({for (final id in ids) _uuid(id)}),
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _sync.merges;
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ typedef CardTagRow = ({String id, String cardId, String tagId});
|
|||
/// assignment, never updated, as for cards.
|
||||
typedef NoteTagRow = ({String id, String noteId, String tagId});
|
||||
|
||||
/// A learning path having a tag, as the device's database stores it: one row
|
||||
/// per assignment, never updated, as for cards.
|
||||
typedef PathTagRow = ({String id, String pathId, String tagId});
|
||||
|
||||
/// The boundary to the tags in this person's local, synced database. Tests
|
||||
/// replace it with a fake.
|
||||
abstract interface class TagGateway() {
|
||||
|
|
@ -23,13 +27,17 @@ abstract interface class TagGateway() {
|
|||
/// Every note's tags on this device, unordered.
|
||||
Future<List<NoteTagRow>> noteTags();
|
||||
|
||||
/// Every learning path's tags on this device, unordered.
|
||||
Future<List<PathTagRow>> pathTags();
|
||||
|
||||
/// Stores a new tag and returns its id, a UUID v7 made on the device.
|
||||
Future<String> insertTag(ByteData payload);
|
||||
|
||||
/// Replaces a tag's payload.
|
||||
Future<void> updateTagPayload(String id, ByteData payload);
|
||||
|
||||
/// Deletes the tag [id] and every card's and note's assignment of it.
|
||||
/// Deletes the tag [id] and every card's, note's and path's assignment of
|
||||
/// it.
|
||||
Future<void> deleteTag(String id);
|
||||
|
||||
/// Gives each card in [assignments] its tag.
|
||||
|
|
@ -48,6 +56,14 @@ abstract interface class TagGateway() {
|
|||
/// Deletes the note assignments [ids].
|
||||
Future<void> deleteNoteTags(Set<String> ids);
|
||||
|
||||
/// Gives each learning path in [assignments] its tag.
|
||||
Future<void> insertPathTags(
|
||||
Iterable<({String pathId, String tagId})> assignments,
|
||||
);
|
||||
|
||||
/// Deletes the path assignments [ids].
|
||||
Future<void> deletePathTags(Set<String> ids);
|
||||
|
||||
/// Fires when a sync brought changes from the person's other devices.
|
||||
Stream<void> get remoteChanges;
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue