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:
parent
46855ab251
commit
f922fceb47
10 changed files with 180 additions and 1 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue