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",
|
"studyFewer": "Fewer",
|
||||||
"studyMore": "More",
|
"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",
|
"commonCancel": "Cancel",
|
||||||
"commonClose": "Close",
|
"commonClose": "Close",
|
||||||
"commonUndo": "Undo",
|
"commonUndo": "Undo",
|
||||||
|
|
|
||||||
|
|
@ -1660,6 +1660,36 @@ abstract class AppLocalizations {
|
||||||
/// **'More'**
|
/// **'More'**
|
||||||
String get studyMore;
|
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.
|
/// No description provided for @commonCancel.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -942,6 +942,22 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get studyMore => 'More';
|
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
|
@override
|
||||||
String get commonCancel => 'Cancel';
|
String get commonCancel => 'Cancel';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ abstract interface class ReviewGateway() {
|
||||||
/// Appends reviews; reviews are never changed.
|
/// Appends reviews; reviews are never changed.
|
||||||
Future<void> insert(Iterable<ReviewRow> reviews);
|
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.
|
/// Fires when a sync brought changes from the person's other devices.
|
||||||
Stream<void> get remoteChanges;
|
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.
|
/// Of [reviews], those of [mode] after the last fresh start.
|
||||||
List<Review> _since(List<Review> reviews, StudyMode mode) {
|
List<Review> _since(List<Review> reviews, StudyMode mode) {
|
||||||
DateTime? start;
|
DateTime? start;
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,23 @@ class SyncReviewGateway(
|
||||||
_sync.requestSync();
|
_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
|
@override
|
||||||
Stream<void> get remoteChanges => _sync.merges;
|
Stream<void> get remoteChanges => _sync.merges;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,19 @@ final class const StudysetListEventUnarchiveRequested(
|
||||||
List<Object?> get props => [id];
|
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.
|
/// Work out the studysets' progress again, as after a study session.
|
||||||
final class const StudysetListEventProgressRefreshRequested()
|
final class const StudysetListEventProgressRefreshRequested()
|
||||||
extends StudysetListEvent;
|
extends StudysetListEvent;
|
||||||
|
|
@ -175,7 +188,31 @@ class StudysetListBloc(
|
||||||
(event, emit) => _studysets.unarchive(event.id),
|
(event, emit) => _studysets.unarchive(event.id),
|
||||||
);
|
);
|
||||||
on<StudysetListEventProgressRefreshRequested>(_onProgressRefreshRequested);
|
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
|
/// How often the progress was asked for, so that a slow, older working
|
||||||
/// out never overwrites a newer one.
|
/// out never overwrites a newer one.
|
||||||
|
|
|
||||||
|
|
@ -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/layout/window_size.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.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/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/scroll_hiding_fab.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
|
import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
|
||||||
|
|
@ -481,6 +482,7 @@ class const _StudyBar({
|
||||||
enum _Option() {
|
enum _Option() {
|
||||||
edit,
|
edit,
|
||||||
move,
|
move,
|
||||||
|
reset,
|
||||||
archive,
|
archive,
|
||||||
unarchive,
|
unarchive,
|
||||||
delete,
|
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
|
/// Back to where the person came from; to the studyset it sat in, or to
|
||||||
/// the library, when they came straight here.
|
/// the library, when they came straight here.
|
||||||
void _leave(BuildContext context) {
|
void _leave(BuildContext context) {
|
||||||
|
|
@ -587,6 +609,7 @@ class const _Options({
|
||||||
onSelected: (option) => switch (option) {
|
onSelected: (option) => switch (option) {
|
||||||
_Option.edit => _edit(context),
|
_Option.edit => _edit(context),
|
||||||
_Option.move => _move(context),
|
_Option.move => _move(context),
|
||||||
|
_Option.reset => _reset(context),
|
||||||
_Option.archive => _archive(context, archive: true),
|
_Option.archive => _archive(context, archive: true),
|
||||||
_Option.unarchive => _archive(context, archive: false),
|
_Option.unarchive => _archive(context, archive: false),
|
||||||
_Option.delete => deleteStudysetWithUndo(
|
_Option.delete => deleteStudysetWithUndo(
|
||||||
|
|
@ -598,6 +621,7 @@ class const _Options({
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
item(_Option.edit, LucideIcons.pencil, l10n.studysetMenuEdit),
|
item(_Option.edit, LucideIcons.pencil, l10n.studysetMenuEdit),
|
||||||
item(_Option.move, LucideIcons.folderInput, l10n.studysetMenuMove),
|
item(_Option.move, LucideIcons.folderInput, l10n.studysetMenuMove),
|
||||||
|
item(_Option.reset, LucideIcons.rotateCcw, l10n.studysetMenuReset),
|
||||||
if (studyset.isArchived)
|
if (studyset.isArchived)
|
||||||
item(
|
item(
|
||||||
_Option.unarchive,
|
_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/data/card_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.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/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/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/bloc/studyset_list_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||||
|
|
@ -21,6 +22,7 @@ void main() {
|
||||||
late FakeCardGateway cardGateway;
|
late FakeCardGateway cardGateway;
|
||||||
late CardRepository cards;
|
late CardRepository cards;
|
||||||
late StudysetDeletions deletions;
|
late StudysetDeletions deletions;
|
||||||
|
late ReviewRepository reviews;
|
||||||
late StudysetListBloc bloc;
|
late StudysetListBloc bloc;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
|
|
@ -28,6 +30,7 @@ void main() {
|
||||||
studysets = StudysetRepository(gateway);
|
studysets = StudysetRepository(gateway);
|
||||||
cardGateway = FakeCardGateway();
|
cardGateway = FakeCardGateway();
|
||||||
cards = CardRepository(cardGateway);
|
cards = CardRepository(cardGateway);
|
||||||
|
reviews = ReviewRepository(FakeReviewGateway());
|
||||||
});
|
});
|
||||||
tearDown(() async {
|
tearDown(() async {
|
||||||
await bloc.close();
|
await bloc.close();
|
||||||
|
|
@ -45,7 +48,7 @@ void main() {
|
||||||
bloc = StudysetListBloc(
|
bloc = StudysetListBloc(
|
||||||
studysets,
|
studysets,
|
||||||
cards,
|
cards,
|
||||||
ReviewRepository(FakeReviewGateway()),
|
reviews,
|
||||||
deletions,
|
deletions,
|
||||||
parentId: parentId,
|
parentId: parentId,
|
||||||
)..add(const StudysetListEventStarted());
|
)..add(const StudysetListEventStarted());
|
||||||
|
|
@ -337,4 +340,33 @@ void main() {
|
||||||
await listing(['Kanji', 'Kana']);
|
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
|
@override
|
||||||
Stream<void> get remoteChanges => const Stream.empty();
|
Stream<void> get remoteChanges => const Stream.empty();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue