feat(app): deleting a studyset can be undone for a moment
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:
parent
85c028fe3c
commit
41f536bbe2
8 changed files with 206 additions and 44 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue