feat(app): reset a studyset's progress, with an undo

The studyset's options offer Reset progress: after asking, every card
in it and in the studysets inside it starts afresh as new (a 'reset'
entry each), its history kept; an undo for a few seconds deletes those
entries again.

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:34:44 +02:00
commit f922fceb47
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
10 changed files with 180 additions and 1 deletions

View file

@ -303,6 +303,12 @@
"studyFewer": "Fewer",
"studyMore": "More",
"studysetMenuReset": "Reset progress",
"studysetResetTitle": "Reset progress?",
"studysetResetMessage": "Every flashcard in it, and in the studysets inside it, starts afresh as new, on all your devices. Your study history stays. You can undo this for a few seconds.",
"studysetResetConfirm": "Reset",
"studysetResetDone": "Progress reset",
"commonCancel": "Cancel",
"commonClose": "Close",
"commonUndo": "Undo",

View file

@ -1660,6 +1660,36 @@ abstract class AppLocalizations {
/// **'More'**
String get studyMore;
/// No description provided for @studysetMenuReset.
///
/// In en, this message translates to:
/// **'Reset progress'**
String get studysetMenuReset;
/// No description provided for @studysetResetTitle.
///
/// In en, this message translates to:
/// **'Reset progress?'**
String get studysetResetTitle;
/// No description provided for @studysetResetMessage.
///
/// In en, this message translates to:
/// **'Every flashcard in it, and in the studysets inside it, starts afresh as new, on all your devices. Your study history stays. You can undo this for a few seconds.'**
String get studysetResetMessage;
/// No description provided for @studysetResetConfirm.
///
/// In en, this message translates to:
/// **'Reset'**
String get studysetResetConfirm;
/// No description provided for @studysetResetDone.
///
/// In en, this message translates to:
/// **'Progress reset'**
String get studysetResetDone;
/// No description provided for @commonCancel.
///
/// In en, this message translates to:

View file

@ -942,6 +942,22 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studyMore => 'More';
@override
String get studysetMenuReset => 'Reset progress';
@override
String get studysetResetTitle => 'Reset progress?';
@override
String get studysetResetMessage =>
'Every flashcard in it, and in the studysets inside it, starts afresh as new, on all your devices. Your study history stays. You can undo this for a few seconds.';
@override
String get studysetResetConfirm => 'Reset';
@override
String get studysetResetDone => 'Progress reset';
@override
String get commonCancel => 'Cancel';

View file

@ -17,6 +17,10 @@ abstract interface class ReviewGateway() {
/// Appends reviews; reviews are never changed.
Future<void> insert(Iterable<ReviewRow> reviews);
/// Deletes the fresh starts of the cards [cardIds] made at [at], to undo
/// them.
Future<void> deleteResets(Set<String> cardIds, DateTime at);
/// Fires when a sync brought changes from the person's other devices.
Stream<void> get remoteChanges;
}

View file

@ -64,6 +64,10 @@ class ReviewRepository(final ReviewGateway _gateway) {
),
]);
/// Undoes [reset] of [cardIds] at [at].
Future<void> undoReset(Iterable<String> cardIds, DateTime at) =>
_gateway.deleteResets({...cardIds}, at.toUtc());
/// Of [reviews], those of [mode] after the last fresh start.
List<Review> _since(List<Review> reviews, StudyMode mode) {
DateTime? start;

View file

@ -52,6 +52,23 @@ class SyncReviewGateway(
_sync.requestSync();
}
@override
Future<void> deleteResets(Set<String> cardIds, DateTime at) async {
if (cardIds.isEmpty) {
return;
}
await protocol.ReviewLog.db.deleteWhere(
_db,
where: (t) =>
t.cardId.inSet({
for (final id in cardIds) protocol.UuidValue.withValidation(id),
}) &
t.mode.equals('reset') &
t.reviewedAt.equals(at),
);
_sync.requestSync();
}
@override
Stream<void> get remoteChanges => _sync.merges;
}

View file

@ -96,6 +96,19 @@ final class const StudysetListEventUnarchiveRequested(
List<Object?> get props => [id];
}
/// Start the progress of every card in the studyset [id], and in what is
/// inside it, afresh.
final class const StudysetListEventResetRequested(
/// The studyset's id.
final String id,
) extends StudysetListEvent {
@override
List<Object?> get props => [id];
}
/// Undo the last fresh start.
final class const StudysetListEventResetUndone() extends StudysetListEvent;
/// Work out the studysets' progress again, as after a study session.
final class const StudysetListEventProgressRefreshRequested()
extends StudysetListEvent;
@ -175,8 +188,32 @@ class StudysetListBloc(
(event, emit) => _studysets.unarchive(event.id),
);
on<StudysetListEventProgressRefreshRequested>(_onProgressRefreshRequested);
on<StudysetListEventResetRequested>((event, emit) async {
final at = clock.now().toUtc();
final cardIds = [
for (final id in subtreeOf(event.id, _studysetsRead))
for (final card in await _cards.cardsOf(id)) card.id,
];
_resetting = _reviews
.reset(cardIds, at)
.then((_) => (cardIds: cardIds, at: at));
await _resetting;
add(const StudysetListEventProgressRefreshRequested());
});
on<StudysetListEventResetUndone>((event, emit) async {
final resetting = _resetting;
_resetting = null;
if (resetting != null) {
final (:cardIds, :at) = await resetting;
await _reviews.undoReset(cardIds, at);
add(const StudysetListEventProgressRefreshRequested());
}
});
}
/// The last fresh start, which an undo takes back.
Future<({List<String> cardIds, DateTime at})>? _resetting;
/// How often the progress was asked for, so that a slow, older working
/// out never overwrites a newer one.
var _progressRequests = 0;

View file

@ -8,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/core/widget/scroll_hiding_fab.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
@ -481,6 +482,7 @@ class const _StudyBar({
enum _Option() {
edit,
move,
reset,
archive,
unarchive,
delete,
@ -555,6 +557,26 @@ class const _Options({
);
}
Future<void> _reset(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final bloc = context.read<StudysetListBloc>();
if (!await confirmDestructiveAction(
context,
title: l10n.studysetResetTitle,
message: l10n.studysetResetMessage,
confirmLabel: l10n.studysetResetConfirm,
) ||
!context.mounted) {
return;
}
bloc.add(StudysetListEventResetRequested(studyset.id));
showUndoSnackBar(
context,
message: l10n.studysetResetDone,
onUndo: () => bloc.add(const StudysetListEventResetUndone()),
);
}
/// Back to where the person came from; to the studyset it sat in, or to
/// the library, when they came straight here.
void _leave(BuildContext context) {
@ -587,6 +609,7 @@ class const _Options({
onSelected: (option) => switch (option) {
_Option.edit => _edit(context),
_Option.move => _move(context),
_Option.reset => _reset(context),
_Option.archive => _archive(context, archive: true),
_Option.unarchive => _archive(context, archive: false),
_Option.delete => deleteStudysetWithUndo(
@ -598,6 +621,7 @@ class const _Options({
itemBuilder: (context) => [
item(_Option.edit, LucideIcons.pencil, l10n.studysetMenuEdit),
item(_Option.move, LucideIcons.folderInput, l10n.studysetMenuMove),
item(_Option.reset, LucideIcons.rotateCcw, l10n.studysetMenuReset),
if (studyset.isArchived)
item(
_Option.unarchive,

View file

@ -3,6 +3,7 @@ import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
@ -21,6 +22,7 @@ void main() {
late FakeCardGateway cardGateway;
late CardRepository cards;
late StudysetDeletions deletions;
late ReviewRepository reviews;
late StudysetListBloc bloc;
setUp(() {
@ -28,6 +30,7 @@ void main() {
studysets = StudysetRepository(gateway);
cardGateway = FakeCardGateway();
cards = CardRepository(cardGateway);
reviews = ReviewRepository(FakeReviewGateway());
});
tearDown(() async {
await bloc.close();
@ -45,7 +48,7 @@ void main() {
bloc = StudysetListBloc(
studysets,
cards,
ReviewRepository(FakeReviewGateway()),
reviews,
deletions,
parentId: parentId,
)..add(const StudysetListEventStarted());
@ -337,4 +340,33 @@ void main() {
await listing(['Kanji', 'Kana']);
});
});
test('resetting starts the progress of a studyset and what is inside it '
'afresh; an undo takes it back', () async {
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
final kanji = await studysets.create(
StudysetContent.titled('Kanji'),
parentId: japanese,
);
for (final studyset in [japanese, kanji]) {
final id = await cards.create(studyset, newCard());
await reviews.record(
cardId: id,
rating: Rating.easy,
reviewedAt: DateTime.now().toUtc(),
duration: Duration.zero,
);
}
start();
Future<int> dueIn(String id) async => (await bloc.stream.firstWhere(
(s) => s is StudysetListStateLoaded && s.progress.containsKey(id),
) as StudysetListStateLoaded).progress[id]!.due;
await listing(['Japanese']);
bloc.add(StudysetListEventResetRequested(japanese));
expect(await dueIn(japanese), 2);
bloc.add(const StudysetListEventResetUndone());
expect(await dueIn(japanese), 0);
});
}

View file

@ -27,6 +27,15 @@ class FakeReviewGateway() implements ReviewGateway {
}
}
@override
Future<void> deleteResets(Set<String> cardIds, DateTime at) async =>
rows.removeWhere(
(row) =>
cardIds.contains(row.cardId) &&
row.mode == 'reset' &&
row.reviewedAt == at,
);
@override
Stream<void> get remoteChanges => const Stream.empty();
}