feat(app): deleting a studyset can be undone for a moment
Some checks failed
CI / check (push) Failing after 24s
CI / deploy (push) Has been skipped

Decided 2026-10-01. Deleting still asks first; once confirmed, the studyset
leaves the library at once and "Studyset deleted" offers Undo for three
seconds. Only when the time to undo is over (four seconds: the message's
three and a moment for a late tap) are its cards and then the studyset
blanked and deleted, since blanking can't be undone (ADR 0002, rule 2). A
deletion still waiting when the library closes (the app quits, the person
changes) is dropped, and the studyset stays.

The library's bloc keeps the waiting deletions; the event that ends the
time to undo is private, so no screen can skip it. AGENTS.md now describes
the library's shared widgets, the sheets (and why a sheet that asks before
discarding can't be swiped away), the order of studysets, and this Undo.

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 08:55:39 +02:00
commit 41f536bbe2
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 206 additions and 44 deletions

View file

@ -92,7 +92,8 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved. - **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved.
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below, and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library); what needs the whole window (studying, a card, settings) is a top-level route over the tabs, reached with `context.push`. Explore stays empty until something is shared. - **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below, and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library); what needs the whole window (studying, a card, settings) is a top-level route over the tabs, reached with `context.push`. Explore stays empty until something is shared.
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone. - **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings); `PaletteColor` for the 20 colours a person can give content. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`); the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`). - **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star, which needs the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, and the sheets below).
- **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47).
- **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/app_en.arb`; English only for now): no string a person reads is written into a widget, except in older screens until they are restyled. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` file. - **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/app_en.arb`; English only for now): no string a person reads is written into a widget, except in older screens until they are restyled. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` file.
## Testing ## Testing
@ -126,11 +127,11 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent. - **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent.
- **Granting a scope** goes through a function like `grantIdentified`, which updates the person *and* their session rows. - **Granting a scope** goes through a function like `grantIdentified`, which updates the person *and* their session rows.
- **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read. - **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read.
- **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced. - **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Since blanking can't be undone, an Undo comes before it: the library hides a deleted studyset at once and blanks and deletes it only once the time to undo is over (`StudysetListBloc`), and drops a deletion still waiting when it closes. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced.
- **Content people edit is append-only revisions** (ADR 0001 rule 2, ADR 0003; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes. - **Content people edit is append-only revisions** (ADR 0001 rule 2, ADR 0003; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes.
- **Reviews are an append-only log** (`ReviewLog`: card, rating, `reviewedAt` in UTC, `durationMs`). A card's schedule is never stored: `scheduleOf` (`feature/study/model/card_schedule.dart`) replays its reviews in time order with FSRS-6 (`fsrs`, pinned exactly), and only the final due date is fuzzed, from the card's id and review count, so every device computes the same. The study queue (`study_queue.dart`): due cards earliest first, then new cards in studyset order, 20 per studyset and local day. Deleting a card deletes its reviews. - **Reviews are an append-only log** (`ReviewLog`: card, rating, `reviewedAt` in UTC, `durationMs`). A card's schedule is never stored: `scheduleOf` (`feature/study/model/card_schedule.dart`) replays its reviews in time order with FSRS-6 (`fsrs`, pinned exactly), and only the final due date is fuzzed, from the card's id and review count, so every device computes the same. The study queue (`study_queue.dart`): due cards earliest first, then new cards in studyset order, 20 per studyset and local day. Deleting a card deletes its reviews.
- **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id. - **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id.
- **Tree, tags, modes, settings and files** (ADR 0004): a studyset's `parent` and sibling `position` form the tree, and studysets in a cycle or without their parent show at the top level; a card's tags are `card_tag` rows, one per assignment, never updated, and a duplicate counts once; `review_log.mode` is `review`, `reverse` (its own schedule) or `cram` (never moves a schedule), and a schedule replays the reviews of one mode; `person_settings` is meant as one row per person, and extra rows fold into the one with the smallest id; an `asset` row stands for a file in private cloud storage, found by its hash. **Never derive a row's id from a person or anything else guessable**: ids are unique across the whole table, so someone could claim it first in their own space and break the owner's sync. - **Tree, tags, modes, settings and files** (ADR 0004): a studyset's `parent` and sibling `position` form the tree, and studysets in a cycle or without their parent show at the top level (`topLevel`); its `archivedAt`, `starredAt` and `updatedAt` are columns, and every change of a studyset writes `updatedAt` with it (the library lists starred studysets first, then the most recently changed); a card's tags are `card_tag` rows, one per assignment, never updated, and a duplicate counts once; `review_log.mode` is `review`, `reverse` (its own schedule) or `cram` (never moves a schedule), and a schedule replays the reviews of one mode; `person_settings` is meant as one row per person, and extra rows fold into the one with the smallest id; an `asset` row stands for a file in private cloud storage, found by its hash. **Never derive a row's id from a person or anything else guessable**: ids are unique across the whole table, so someone could claim it first in their own space and break the owner's sync.
- **Every client migration keeps a device's rows** (`test/core/sync/device_migration_test.dart` replays each on a database with a row in every synced table of ours; SQLite rebuilds tables to add foreign keys, which Serverpod's runner does with foreign keys off). A new synced table needs a row in that test, which fails until it has one. - **Every client migration keeps a device's rows** (`test/core/sync/device_migration_test.dart` replays each on a database with a row in every synced table of ours; SQLite rebuilds tables to add foreign keys, which Serverpod's runner does with foreign keys off). A new synced table needs a row in that test, which fails until it has one.
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables. - **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
- **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device. - **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device.

View file

@ -25,8 +25,9 @@
"librarySearchNoMatches": "No studysets match your search", "librarySearchNoMatches": "No studysets match your search",
"libraryStudysetArchived": "Studyset archived", "libraryStudysetArchived": "Studyset archived",
"libraryStudysetUnarchived": "Studyset unarchived", "libraryStudysetUnarchived": "Studyset unarchived",
"libraryStudysetDeleted": "Studyset deleted",
"libraryDeleteTitle": "Delete studyset?", "libraryDeleteTitle": "Delete studyset?",
"libraryDeleteMessage": "It is deleted with its cards, on all your devices.", "libraryDeleteMessage": "It is deleted with its cards, on all your devices. You can undo this for a few seconds.",
"libraryDeleteConfirm": "Delete", "libraryDeleteConfirm": "Delete",
"studysetSheetCreateTitle": "Create studyset", "studysetSheetCreateTitle": "Create studyset",

View file

@ -214,6 +214,12 @@ abstract class AppLocalizations {
/// **'Studyset unarchived'** /// **'Studyset unarchived'**
String get libraryStudysetUnarchived; String get libraryStudysetUnarchived;
/// No description provided for @libraryStudysetDeleted.
///
/// In en, this message translates to:
/// **'Studyset deleted'**
String get libraryStudysetDeleted;
/// No description provided for @libraryDeleteTitle. /// No description provided for @libraryDeleteTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -223,7 +229,7 @@ abstract class AppLocalizations {
/// No description provided for @libraryDeleteMessage. /// No description provided for @libraryDeleteMessage.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'It is deleted with its cards, on all your devices.'** /// **'It is deleted with its cards, on all your devices. You can undo this for a few seconds.'**
String get libraryDeleteMessage; String get libraryDeleteMessage;
/// No description provided for @libraryDeleteConfirm. /// No description provided for @libraryDeleteConfirm.

View file

@ -70,12 +70,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get libraryStudysetUnarchived => 'Studyset unarchived'; String get libraryStudysetUnarchived => 'Studyset unarchived';
@override
String get libraryStudysetDeleted => 'Studyset deleted';
@override @override
String get libraryDeleteTitle => 'Delete studyset?'; String get libraryDeleteTitle => 'Delete studyset?';
@override @override
String get libraryDeleteMessage => String get libraryDeleteMessage =>
'It is deleted with its cards, on all your devices.'; 'It is deleted with its cards, on all your devices. You can undo this for a few seconds.';
@override @override
String get libraryDeleteConfirm => 'Delete'; String get libraryDeleteConfirm => 'Delete';

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
@ -69,7 +70,8 @@ final class const StudysetListEventUnarchiveRequested(
List<Object?> get props => [id]; List<Object?> get props => [id];
} }
/// Delete the studyset [id] with its cards. /// Delete the studyset [id] with its cards, once the time to undo that is
/// over.
final class const StudysetListEventDeleteRequested( final class const StudysetListEventDeleteRequested(
/// The studyset's id. /// The studyset's id.
final String id, final String id,
@ -78,6 +80,25 @@ final class const StudysetListEventDeleteRequested(
List<Object?> get props => [id]; List<Object?> get props => [id];
} }
/// Keep the studyset [id] after all, while its deletion still waits.
final class const StudysetListEventDeleteUndone(
/// The studyset's id.
final String id,
) extends StudysetListEvent {
@override
List<Object?> get props => [id];
}
/// The time to undo deleting the studyset [id] is over.
// Private, unlike the other events: only the bloc's own timer sends it, so a
// screen can't skip the time to undo.
// ignore: leancode_lint/bloc_subclasses_naming
final class const _StudysetListEventDeletionDue(final String id)
extends StudysetListEvent {
@override
List<Object?> get props => [id];
}
/// 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;
@ -114,11 +135,21 @@ final class const StudysetListStateLoaded(
/// The library: the person's studysets with the progress of their cards, /// The library: the person's studysets with the progress of their cards,
/// following every change, local or synced; creates, edits, stars, archives /// following every change, local or synced; creates, edits, stars, archives
/// and deletes studysets. /// and deletes studysets.
///
/// Deleting can be undone for a moment (decided 2026-10-01): the studyset
/// leaves the list at once, and its content is blanked and deleted only once
/// [_undoWindow] is over, since blanking can't be undone (ADR 0002, rule 2).
/// A deletion still waiting when the library closes is dropped, and the
/// studyset stays.
class StudysetListBloc( class StudysetListBloc(
final StudysetRepository _studysets, final StudysetRepository _studysets,
final CardRepository _cards, final CardRepository _cards,
final ReviewRepository _reviews, final ReviewRepository _reviews, {
) extends Bloc<StudysetListEvent, StudysetListState> {
/// How long a deletion can be undone: the three seconds the message
/// offering it shows, and a moment for a late tap.
final Duration _undoWindow = const Duration(seconds: 4),
}) extends Bloc<StudysetListEvent, StudysetListState> {
this : super(const StudysetListStateLoading()) { this : super(const StudysetListStateLoading()) {
on<StudysetListEventStarted>(_onStarted); on<StudysetListEventStarted>(_onStarted);
on<StudysetListEventCreateRequested>( on<StudysetListEventCreateRequested>(
@ -139,6 +170,8 @@ class StudysetListBloc(
(event, emit) => _studysets.unarchive(event.id), (event, emit) => _studysets.unarchive(event.id),
); );
on<StudysetListEventDeleteRequested>(_onDeleteRequested); on<StudysetListEventDeleteRequested>(_onDeleteRequested);
on<StudysetListEventDeleteUndone>(_onDeleteUndone);
on<_StudysetListEventDeletionDue>(_onDeletionDue);
on<StudysetListEventProgressRefreshRequested>(_onProgressRefreshRequested); on<StudysetListEventProgressRefreshRequested>(_onProgressRefreshRequested);
} }
@ -146,6 +179,35 @@ class StudysetListBloc(
/// out never overwrites a newer one. /// out never overwrites a newer one.
var _progressRequests = 0; var _progressRequests = 0;
/// The person's studysets as last read.
var _studysetsRead = const <Studyset>[];
/// The deletions waiting for the time to undo them to be over, by the
/// studyset's id.
final _pendingDeletions = <String, Timer>{};
@override
Future<void> close() {
for (final timer in _pendingDeletions.values) {
timer.cancel();
}
_pendingDeletions.clear();
return super.close();
}
/// The studysets the library shows: in its order, without those whose
/// deletion waits.
List<Studyset> get _shown => [
for (final studyset in _inLibraryOrder(_studysetsRead))
if (!_pendingDeletions.containsKey(studyset.id)) studyset,
];
/// The last progress worked out.
Map<String, StudyProgress> get _progress => switch (state) {
StudysetListStateLoaded(:final progress) => progress,
_ => const {},
};
Future<void> _onStarted( Future<void> _onStarted(
StudysetListEventStarted event, StudysetListEventStarted event,
Emitter<StudysetListState> emit, Emitter<StudysetListState> emit,
@ -153,15 +215,10 @@ class StudysetListBloc(
emit.forEach( emit.forEach(
_studysets.watchAll(), _studysets.watchAll(),
onData: (studysets) { onData: (studysets) {
_studysetsRead = studysets;
add(const StudysetListEventProgressRefreshRequested()); add(const StudysetListEventProgressRefreshRequested());
return StudysetListStateLoaded( // Until the new progress is worked out, the last one stands.
_inLibraryOrder(studysets), return StudysetListStateLoaded(_shown, progress: _progress);
// Until the new progress is worked out, the last one stands.
progress: switch (state) {
StudysetListStateLoaded(:final progress) => progress,
_ => const {},
},
);
}, },
onError: (_, _) => const StudysetListStateFailed(), onError: (_, _) => const StudysetListStateFailed(),
), ),
@ -172,10 +229,37 @@ class StudysetListBloc(
), ),
]); ]);
Future<void> _onDeleteRequested( void _onDeleteRequested(
StudysetListEventDeleteRequested event, StudysetListEventDeleteRequested event,
Emitter<StudysetListState> emit, Emitter<StudysetListState> emit,
) {
_pendingDeletions[event.id]?.cancel();
_pendingDeletions[event.id] = Timer(
_undoWindow,
() => add(_StudysetListEventDeletionDue(event.id)),
);
if (state is StudysetListStateLoaded) {
emit(StudysetListStateLoaded(_shown, progress: _progress));
}
}
void _onDeleteUndone(
StudysetListEventDeleteUndone event,
Emitter<StudysetListState> emit,
) {
_pendingDeletions.remove(event.id)?.cancel();
if (state is StudysetListStateLoaded) {
emit(StudysetListStateLoaded(_shown, progress: _progress));
}
}
Future<void> _onDeletionDue(
_StudysetListEventDeletionDue event,
Emitter<StudysetListState> emit,
) async { ) async {
if (_pendingDeletions.remove(event.id) == null) {
return;
}
// Its cards first, each blanked, so no card content outlives the // Its cards first, each blanked, so no card content outlives the
// studyset (ADR 0002, rule 2). // studyset (ADR 0002, rule 2).
await _cards.deleteAllOf(event.id); await _cards.deleteAllOf(event.id);

View file

@ -311,14 +311,22 @@ class _GridState() extends State<_Grid> {
bloc.add(StudysetListEventArchiveRequested(studyset.id)), bloc.add(StudysetListEventArchiveRequested(studyset.id)),
); );
}, },
// Asks, then offers to undo for a moment before the deletion happens.
onDelete: () async { onDelete: () async {
if (await confirmDestructiveAction( if (await confirmDestructiveAction(
context, context,
title: l10n.libraryDeleteTitle, title: l10n.libraryDeleteTitle,
message: l10n.libraryDeleteMessage, message: l10n.libraryDeleteMessage,
confirmLabel: l10n.libraryDeleteConfirm, confirmLabel: l10n.libraryDeleteConfirm,
)) { ) &&
mounted) {
bloc.add(StudysetListEventDeleteRequested(studyset.id)); bloc.add(StudysetListEventDeleteRequested(studyset.id));
// The grid's context, not the card's: the card is about to go.
showUndoSnackBar(
this.context,
message: l10n.libraryStudysetDeleted,
onUndo: () => bloc.add(StudysetListEventDeleteUndone(studyset.id)),
);
} }
}, },
); );

View file

@ -25,11 +25,6 @@ void main() {
studysets = StudysetRepository(gateway); studysets = StudysetRepository(gateway);
cardGateway = FakeCardGateway(); cardGateway = FakeCardGateway();
cards = CardRepository(cardGateway); cards = CardRepository(cardGateway);
bloc = StudysetListBloc(
studysets,
cards,
ReviewRepository(FakeReviewGateway()),
);
}); });
tearDown(() async { tearDown(() async {
await bloc.close(); await bloc.close();
@ -37,6 +32,16 @@ void main() {
await cards.dispose(); await cards.dispose();
}); });
/// Opens the library, where a deletion can be undone for [undoWindow].
void start({Duration undoWindow = const Duration(milliseconds: 1)}) {
bloc = StudysetListBloc(
studysets,
cards,
ReviewRepository(FakeReviewGateway()),
undoWindow: undoWindow,
)..add(const StudysetListEventStarted());
}
/// The next state that lists [titles], each studyset with its progress. /// The next state that lists [titles], each studyset with its progress.
Future<StudysetListStateLoaded> listing(List<String> titles) async { Future<StudysetListStateLoaded> listing(List<String> titles) async {
bool matches(StudysetListState state) => bool matches(StudysetListState state) =>
@ -62,7 +67,7 @@ void main() {
final german = await studysets.create(StudysetContent.titled('German')); final german = await studysets.create(StudysetContent.titled('German'));
await cards.create(japanese, newCard()); await cards.create(japanese, newCard());
bloc.add(const StudysetListEventStarted()); start();
// German changed last: it was created after Japanese. // German changed last: it was created after Japanese.
final state = await listing(['German', 'Japanese']); final state = await listing(['German', 'Japanese']);
@ -77,7 +82,7 @@ void main() {
final german = await studysets.create(StudysetContent.titled('German')); final german = await studysets.create(StudysetContent.titled('German'));
await studysets.star(german); await studysets.star(german);
bloc.add(const StudysetListEventStarted()); start();
await listing(['German', 'Japanese']); await listing(['German', 'Japanese']);
}); });
@ -108,7 +113,7 @@ void main() {
updatedAt: at(9), updatedAt: at(9),
); );
bloc.add(const StudysetListEventStarted()); start();
await listing(['Starred', 'Later', 'Earlier', 'Old']); await listing(['Starred', 'Later', 'Earlier', 'Old']);
}, },
@ -122,13 +127,13 @@ void main() {
parentId: 'studyset-1', parentId: 'studyset-1',
); );
bloc.add(const StudysetListEventStarted()); start();
await listing(['Japanese']); await listing(['Japanese']);
}); });
test('follows what is created, and the progress as cards change', () async { test('follows what is created, and the progress as cards change', () async {
bloc.add(const StudysetListEventStarted()); start();
await listing([]); await listing([]);
bloc.add( bloc.add(
@ -148,7 +153,7 @@ void main() {
test("changes a studyset's content", () async { test("changes a studyset's content", () async {
final id = await studysets.create(StudysetContent.titled('Japanese')); final id = await studysets.create(StudysetContent.titled('Japanese'));
bloc.add(const StudysetListEventStarted()); start();
await listing(['Japanese']); await listing(['Japanese']);
bloc.add( bloc.add(
@ -160,7 +165,7 @@ void main() {
test('says so when the studysets cannot be read, then recovers', () async { test('says so when the studysets cannot be read, then recovers', () async {
gateway.failReads = Exception('Disk unavailable'); gateway.failReads = Exception('Disk unavailable');
bloc.add(const StudysetListEventStarted()); start();
await expectLater(bloc.stream, emits(const StudysetListStateFailed())); await expectLater(bloc.stream, emits(const StudysetListStateFailed()));
gateway.failReads = null; gateway.failReads = null;
@ -171,7 +176,7 @@ void main() {
test('stars a studyset and takes the star away', () async { test('stars a studyset and takes the star away', () async {
await studysets.create(StudysetContent.titled('Japanese')); await studysets.create(StudysetContent.titled('Japanese'));
bloc.add(const StudysetListEventStarted()); start();
final studyset = (await listing(['Japanese'])).studysets.single; final studyset = (await listing(['Japanese'])).studysets.single;
bloc.add(StudysetListEventStarToggled(studyset)); bloc.add(StudysetListEventStarToggled(studyset));
@ -193,7 +198,7 @@ void main() {
test('archives a studyset and takes it out again', () async { test('archives a studyset and takes it out again', () async {
final id = await studysets.create(StudysetContent.titled('Japanese')); final id = await studysets.create(StudysetContent.titled('Japanese'));
bloc.add(const StudysetListEventStarted()); start();
await listing(['Japanese']); await listing(['Japanese']);
bloc.add(StudysetListEventArchiveRequested(id)); bloc.add(StudysetListEventArchiveRequested(id));
@ -210,16 +215,48 @@ void main() {
); );
}); });
test('deletes a studyset with its cards, each blanked first', () async { test('deleting hides a studyset at once, and deletes it with its cards, '
'each blanked first, once the time to undo is over', () async {
final id = await studysets.create(StudysetContent.titled('Japanese')); final id = await studysets.create(StudysetContent.titled('Japanese'));
final card = await cards.create(id, newCard()); final card = await cards.create(id, newCard());
bloc.add(const StudysetListEventStarted()); start(undoWindow: const Duration(milliseconds: 50));
await listing(['Japanese']); await listing(['Japanese']);
bloc.add(StudysetListEventDeleteRequested(id)); bloc.add(StudysetListEventDeleteRequested(id));
await listing([]); await listing([]);
expect(gateway.deleted, isEmpty);
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(cardGateway.deletions, ['blank $card', 'delete $card']); expect(cardGateway.deletions, ['blank $card', 'delete $card']);
expect(gateway.deleted, [id]); expect(gateway.deleted, [id]);
await listing([]);
});
test('undoing in time keeps the studyset', () async {
final id = await studysets.create(StudysetContent.titled('Japanese'));
start(undoWindow: const Duration(hours: 1));
await listing(['Japanese']);
bloc.add(StudysetListEventDeleteRequested(id));
await listing([]);
bloc.add(StudysetListEventDeleteUndone(id));
await listing(['Japanese']);
expect(gateway.deleted, isEmpty);
});
test('a deletion still waiting when the library closes is dropped, and '
'the studyset stays', () async {
final id = await studysets.create(StudysetContent.titled('Japanese'));
start(undoWindow: const Duration(milliseconds: 50));
await listing(['Japanese']);
bloc.add(StudysetListEventDeleteRequested(id));
await listing([]);
await bloc.close();
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(gateway.deleted, isEmpty);
expect(await studysets.all(), hasLength(1));
}); });
} }

View file

@ -336,9 +336,8 @@ void main() {
expect(gateway.deleted, isEmpty); expect(gateway.deleted, isEmpty);
}); });
testWidgets('a confirmed delete removes the studyset with its cards', ( testWidgets('a confirmed delete hides the studyset, offers to undo, and '
tester, 'then deletes it with its cards', (tester) async {
) async {
stored('japanese', 'Japanese'); stored('japanese', 'Japanese');
final cards = CardRepository(cardGateway); final cards = CardRepository(cardGateway);
addTearDown(cards.dispose); addTearDown(cards.dispose);
@ -351,12 +350,35 @@ void main() {
await choose(tester, 'Japanese', 'Delete'); await choose(tester, 'Japanese', 'Delete');
await tester.tap(find.widgetWithText(FilledButton, 'Delete')); await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Studyset deleted'), findsOneWidget);
expect(find.text('No studysets yet'), findsOneWidget); expect(find.text('No studysets yet'), findsOneWidget);
expect(gateway.deleted, isEmpty);
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
expect(gateway.deleted, ['japanese']); expect(gateway.deleted, ['japanese']);
expect(cardGateway.deletions, ['blank $card', 'delete $card']); expect(cardGateway.deletions, ['blank $card', 'delete $card']);
}); });
testWidgets('Undo brings a deleted studyset back, and nothing is deleted', (
tester,
) async {
stored('japanese', 'Japanese');
await showLibrary(tester);
await choose(tester, 'Japanese', 'Delete');
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle();
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
expect(find.text('Japanese'), findsOneWidget);
expect(gateway.deleted, isEmpty);
});
testWidgets('a card invites studying its due cards, and counts anew after ' testWidgets('a card invites studying its due cards, and counts anew after '
'studying', (tester) async { 'studying', (tester) async {
stored('japanese', 'Japanese'); stored('japanese', 'Japanese');