feat(studyset): deleted studysets wait 14 days in Recently deleted
Deleting a studyset blanked it with everything inside it once a four-second Undo was over. It now only marks it (`studyset.deletedAt`, a column as `archivedAt`): it leaves every list, count and session with what is inside it and waits in the library's Recently deleted for 14 days, saying how many are left, with Restore and Delete now. Deleting no longer asks first and offers Undo; Delete now asks. Once the 14 days are over, whichever of the person's devices looks next (as their data opens, after every sync) deletes it for good, blanking first as before; the server can't blank a payload and never writes a person's space (ADR 0007). The connector, publishing and the learner count leave deleted studysets out too. The migration adds one nullable column: app versions before it stop syncing until they update. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
11a16f5686
commit
4644125cc4
29 changed files with 26969 additions and 313 deletions
|
|
@ -24,10 +24,18 @@
|
|||
"librarySearchNoMatches": "Keine Lernsets für deine Suche gefunden",
|
||||
"libraryStudysetArchived": "Lernset archiviert",
|
||||
"libraryStudysetUnarchived": "Lernset aus dem Archiv geholt",
|
||||
"libraryStudysetDeleted": "Lernset gelöscht",
|
||||
"libraryDeleteTitle": "Lernset löschen?",
|
||||
"libraryDeleteMessage": "Es wird samt allem, was darin ist, und allen zugehörigen Karten auf all deinen Geräten gelöscht. Du kannst dies für einige Sekunden rückgängig machen.",
|
||||
"libraryDeleteConfirm": "Löschen",
|
||||
"libraryStudysetDeleted": "In „Zuletzt gelöscht“ verschoben",
|
||||
"libraryDeletedSection": "Zuletzt gelöscht",
|
||||
"libraryDeletedNote": "Gelöschte Lernsets werden samt allem, was darin ist, 14 Tage aufbewahrt. Danach werden sie endgültig gelöscht.",
|
||||
"libraryDeletedDaysLeft": "{days, plural, =0{Bald endgültig gelöscht} =1{Noch 1 Tag} other{Noch {days} Tage}}",
|
||||
"@libraryDeletedDaysLeft": {"placeholders": {"days": {"type": "int"}}},
|
||||
"libraryRestore": "Wiederherstellen",
|
||||
"libraryDeleteNow": "Jetzt löschen",
|
||||
"libraryStudysetRestored": "Lernset wiederhergestellt",
|
||||
"libraryDeleteForGoodTitle": "Endgültig löschen?",
|
||||
"libraryDeleteForGoodMessage": "„{title}“ wird samt allem, was darin ist, und allen zugehörigen Karten auf all deinen Geräten gelöscht. Das lässt sich nicht rückgängig machen.",
|
||||
"@libraryDeleteForGoodMessage": {"placeholders": {"title": {"type": "String"}}},
|
||||
"libraryDeleteForGoodConfirm": "Endgültig löschen",
|
||||
|
||||
"studysetSheetCreateTitle": "Lernset erstellen",
|
||||
"studysetSheetEditTitle": "Lernset bearbeiten",
|
||||
|
|
|
|||
|
|
@ -25,10 +25,26 @@
|
|||
"librarySearchNoMatches": "No studysets match your search",
|
||||
"libraryStudysetArchived": "Studyset archived",
|
||||
"libraryStudysetUnarchived": "Studyset unarchived",
|
||||
"libraryStudysetDeleted": "Studyset deleted",
|
||||
"libraryDeleteTitle": "Delete studyset?",
|
||||
"libraryDeleteMessage": "It is deleted with everything inside it and all their cards, on all your devices. You can undo this for a few seconds.",
|
||||
"libraryDeleteConfirm": "Delete",
|
||||
"libraryStudysetDeleted": "Moved to Recently deleted",
|
||||
"libraryDeletedSection": "Recently deleted",
|
||||
"libraryDeletedNote": "Deleted studysets are kept for 14 days, with everything inside them. After that, they are deleted for good.",
|
||||
"libraryDeletedDaysLeft": "{days, plural, =0{Deleted for good soon} =1{1 day left} other{{days} days left}}",
|
||||
"@libraryDeletedDaysLeft": {
|
||||
"placeholders": {
|
||||
"days": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"libraryRestore": "Restore",
|
||||
"libraryDeleteNow": "Delete now",
|
||||
"libraryStudysetRestored": "Studyset restored",
|
||||
"libraryDeleteForGoodTitle": "Delete for good?",
|
||||
"libraryDeleteForGoodMessage": "“{title}” is deleted with everything inside it and all their cards, on all your devices. This can't be undone.",
|
||||
"@libraryDeleteForGoodMessage": {
|
||||
"placeholders": {
|
||||
"title": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"libraryDeleteForGoodConfirm": "Delete for good",
|
||||
|
||||
"studysetSheetCreateTitle": "Create studyset",
|
||||
"studysetSheetEditTitle": "Edit studyset",
|
||||
|
|
|
|||
|
|
@ -223,26 +223,62 @@ abstract class AppLocalizations {
|
|||
/// No description provided for @libraryStudysetDeleted.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Studyset deleted'**
|
||||
/// **'Moved to Recently deleted'**
|
||||
String get libraryStudysetDeleted;
|
||||
|
||||
/// No description provided for @libraryDeleteTitle.
|
||||
/// No description provided for @libraryDeletedSection.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete studyset?'**
|
||||
String get libraryDeleteTitle;
|
||||
/// **'Recently deleted'**
|
||||
String get libraryDeletedSection;
|
||||
|
||||
/// No description provided for @libraryDeleteMessage.
|
||||
/// No description provided for @libraryDeletedNote.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'It is deleted with everything inside it and all their cards, on all your devices. You can undo this for a few seconds.'**
|
||||
String get libraryDeleteMessage;
|
||||
/// **'Deleted studysets are kept for 14 days, with everything inside them. After that, they are deleted for good.'**
|
||||
String get libraryDeletedNote;
|
||||
|
||||
/// No description provided for @libraryDeleteConfirm.
|
||||
/// No description provided for @libraryDeletedDaysLeft.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete'**
|
||||
String get libraryDeleteConfirm;
|
||||
/// **'{days, plural, =0{Deleted for good soon} =1{1 day left} other{{days} days left}}'**
|
||||
String libraryDeletedDaysLeft(int days);
|
||||
|
||||
/// No description provided for @libraryRestore.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Restore'**
|
||||
String get libraryRestore;
|
||||
|
||||
/// No description provided for @libraryDeleteNow.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete now'**
|
||||
String get libraryDeleteNow;
|
||||
|
||||
/// No description provided for @libraryStudysetRestored.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Studyset restored'**
|
||||
String get libraryStudysetRestored;
|
||||
|
||||
/// No description provided for @libraryDeleteForGoodTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete for good?'**
|
||||
String get libraryDeleteForGoodTitle;
|
||||
|
||||
/// No description provided for @libraryDeleteForGoodMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'“{title}” is deleted with everything inside it and all their cards, on all your devices. This can\'t be undone.'**
|
||||
String libraryDeleteForGoodMessage(String title);
|
||||
|
||||
/// No description provided for @libraryDeleteForGoodConfirm.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete for good'**
|
||||
String get libraryDeleteForGoodConfirm;
|
||||
|
||||
/// No description provided for @studysetSheetCreateTitle.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -72,17 +72,46 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get libraryStudysetUnarchived => 'Lernset aus dem Archiv geholt';
|
||||
|
||||
@override
|
||||
String get libraryStudysetDeleted => 'Lernset gelöscht';
|
||||
String get libraryStudysetDeleted => 'In „Zuletzt gelöscht“ verschoben';
|
||||
|
||||
@override
|
||||
String get libraryDeleteTitle => 'Lernset löschen?';
|
||||
String get libraryDeletedSection => 'Zuletzt gelöscht';
|
||||
|
||||
@override
|
||||
String get libraryDeleteMessage =>
|
||||
'Es wird samt allem, was darin ist, und allen zugehörigen Karten auf all deinen Geräten gelöscht. Du kannst dies für einige Sekunden rückgängig machen.';
|
||||
String get libraryDeletedNote =>
|
||||
'Gelöschte Lernsets werden samt allem, was darin ist, 14 Tage aufbewahrt. Danach werden sie endgültig gelöscht.';
|
||||
|
||||
@override
|
||||
String get libraryDeleteConfirm => 'Löschen';
|
||||
String libraryDeletedDaysLeft(int days) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
days,
|
||||
locale: localeName,
|
||||
other: 'Noch $days Tage',
|
||||
one: 'Noch 1 Tag',
|
||||
zero: 'Bald endgültig gelöscht',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryRestore => 'Wiederherstellen';
|
||||
|
||||
@override
|
||||
String get libraryDeleteNow => 'Jetzt löschen';
|
||||
|
||||
@override
|
||||
String get libraryStudysetRestored => 'Lernset wiederhergestellt';
|
||||
|
||||
@override
|
||||
String get libraryDeleteForGoodTitle => 'Endgültig löschen?';
|
||||
|
||||
@override
|
||||
String libraryDeleteForGoodMessage(String title) {
|
||||
return '„$title“ wird samt allem, was darin ist, und allen zugehörigen Karten auf all deinen Geräten gelöscht. Das lässt sich nicht rückgängig machen.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryDeleteForGoodConfirm => 'Endgültig löschen';
|
||||
|
||||
@override
|
||||
String get studysetSheetCreateTitle => 'Lernset erstellen';
|
||||
|
|
|
|||
|
|
@ -70,17 +70,46 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get libraryStudysetUnarchived => 'Studyset unarchived';
|
||||
|
||||
@override
|
||||
String get libraryStudysetDeleted => 'Studyset deleted';
|
||||
String get libraryStudysetDeleted => 'Moved to Recently deleted';
|
||||
|
||||
@override
|
||||
String get libraryDeleteTitle => 'Delete studyset?';
|
||||
String get libraryDeletedSection => 'Recently deleted';
|
||||
|
||||
@override
|
||||
String get libraryDeleteMessage =>
|
||||
'It is deleted with everything inside it and all their cards, on all your devices. You can undo this for a few seconds.';
|
||||
String get libraryDeletedNote =>
|
||||
'Deleted studysets are kept for 14 days, with everything inside them. After that, they are deleted for good.';
|
||||
|
||||
@override
|
||||
String get libraryDeleteConfirm => 'Delete';
|
||||
String libraryDeletedDaysLeft(int days) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
days,
|
||||
locale: localeName,
|
||||
other: '$days days left',
|
||||
one: '1 day left',
|
||||
zero: 'Deleted for good soon',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryRestore => 'Restore';
|
||||
|
||||
@override
|
||||
String get libraryDeleteNow => 'Delete now';
|
||||
|
||||
@override
|
||||
String get libraryStudysetRestored => 'Studyset restored';
|
||||
|
||||
@override
|
||||
String get libraryDeleteForGoodTitle => 'Delete for good?';
|
||||
|
||||
@override
|
||||
String libraryDeleteForGoodMessage(String title) {
|
||||
return '“$title” is deleted with everything inside it and all their cards, on all your devices. This can\'t be undone.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryDeleteForGoodConfirm => 'Delete for good';
|
||||
|
||||
@override
|
||||
String get studysetSheetCreateTitle => 'Create studyset';
|
||||
|
|
|
|||
|
|
@ -355,7 +355,7 @@ Future<PersonDependencies> _openPerson(
|
|||
notes,
|
||||
paths,
|
||||
pdfSources,
|
||||
);
|
||||
)..deleteExpiredOn(sync.synced);
|
||||
final noteDeletions = NoteDeletions(notes);
|
||||
final cardDeletions = CardDeletions(cards);
|
||||
final pathDeletions = LearningPathDeletions(paths);
|
||||
|
|
@ -411,7 +411,8 @@ Future<PersonDependencies> _openPerson(
|
|||
onDispose: () async {
|
||||
try {
|
||||
// Deletions first: those still waiting are dropped, those under way
|
||||
// finish while their repositories are open.
|
||||
// (a studyset's for good too) finish while their repositories are
|
||||
// open.
|
||||
await deletions.dispose();
|
||||
await noteDeletions.dispose();
|
||||
await cardDeletions.dispose();
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ class PersonDependencies({
|
|||
/// The files of the person's content: images, sounds and PDFs.
|
||||
required final AssetRepository assets,
|
||||
|
||||
/// The studyset deletions that can still be undone.
|
||||
/// The deleted studysets: Recently deleted, and deleting them for good.
|
||||
required final StudysetDeletions deletions,
|
||||
|
||||
/// The note deletions that can still be undone.
|
||||
|
|
|
|||
|
|
@ -65,8 +65,9 @@ class GitHubSignInBloc(
|
|||
case GitHubLinkOutcome.cancelled:
|
||||
emit(const GitHubSignInStateReady());
|
||||
case GitHubLinkOutcome.takenByAnotherPerson:
|
||||
// This device's content is its studysets, for now.
|
||||
if ((await _studysets.all()).isEmpty) {
|
||||
// This device's content is its studysets, for now, those waiting
|
||||
// in Recently deleted too.
|
||||
if ((await _studysets.withDeleted()).isEmpty) {
|
||||
await _switch(emit);
|
||||
} else {
|
||||
emit(const GitHubSignInStateConfirmSwitch());
|
||||
|
|
|
|||
|
|
@ -95,8 +95,27 @@ class HomeBloc(
|
|||
final refresh = ++_refreshes;
|
||||
try {
|
||||
final now = clock.now();
|
||||
final reviews = await _reviews.all();
|
||||
final cards = await _cards.allCards();
|
||||
// What waits in Recently deleted counts nowhere, as if it were gone,
|
||||
// until it is restored.
|
||||
final everything = await _studysets.withDeleted();
|
||||
final kept = {for (final studyset in notDeleted(everything)) studyset.id};
|
||||
final waiting = {
|
||||
for (final studyset in everything)
|
||||
if (!kept.contains(studyset.id)) studyset.id,
|
||||
};
|
||||
final allCards = await _cards.allCards();
|
||||
final cards = [
|
||||
for (final card in allCards)
|
||||
if (!waiting.contains(card.studysetId)) card,
|
||||
];
|
||||
final gone = {
|
||||
for (final card in allCards)
|
||||
if (waiting.contains(card.studysetId)) card.id,
|
||||
};
|
||||
final reviews = [
|
||||
for (final review in await _reviews.all())
|
||||
if (!gone.contains(review.cardId)) review,
|
||||
];
|
||||
final steps = await _pathProgress.doneTimes();
|
||||
final ratings = [
|
||||
for (final review in reviews)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.da
|
|||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/reading_deadlines.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
|
||||
/// The person's reading deadlines today (see [readingDeadlinesOf]), worked
|
||||
/// out anew after every change of their PDFs, their reading or their
|
||||
|
|
@ -33,16 +34,21 @@ class ReadingDeadlinesBloc(
|
|||
}
|
||||
|
||||
Future<ReadingDeadlinesState> _read() async {
|
||||
final studysets = await _studysets.all();
|
||||
final everything = await _studysets.withDeleted();
|
||||
final kept = notDeleted(everything);
|
||||
final keptIds = {for (final studyset in kept) studyset.id};
|
||||
return ReadingDeadlinesState(
|
||||
loaded: true,
|
||||
deadlines: readingDeadlinesOf(
|
||||
await _pdfs.all(),
|
||||
furthest: await _pdfs.furthestPages(),
|
||||
today: calendarDayOf(clock.now()),
|
||||
archived: {
|
||||
for (final Studyset studyset in studysets)
|
||||
if (studyset.isArchived) studyset.id,
|
||||
// Archived with what is inside, or waiting in Recently deleted.
|
||||
resting: {
|
||||
for (final Studyset studyset in everything)
|
||||
if (!keptIds.contains(studyset.id)) studyset.id,
|
||||
for (final Studyset studyset in kept)
|
||||
if (studyset.isArchived) ...subtreeOf(studyset.id, kept),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,18 +13,19 @@ typedef ReadingDeadlines = ({
|
|||
});
|
||||
|
||||
/// The reading deadlines of [pdfs] [today], with [furthest] pages reached
|
||||
/// (by PDF id); met ones and those of [archived] studysets left out.
|
||||
/// (by PDF id); met ones and those of [resting] studysets (archived or
|
||||
/// deleted, or inside one) left out.
|
||||
ReadingDeadlines readingDeadlinesOf(
|
||||
List<PdfSource> pdfs, {
|
||||
required Map<String, int> furthest,
|
||||
required CalendarDay today,
|
||||
required Set<String> archived,
|
||||
required Set<String> resting,
|
||||
}) {
|
||||
final current = <PdfDeadline>[];
|
||||
final upcoming = <PdfDeadline>[];
|
||||
for (final pdf in pdfs) {
|
||||
final deadline = pdf.content.deadline;
|
||||
if (deadline == null || archived.contains(pdf.studysetId)) {
|
||||
if (deadline == null || resting.contains(pdf.studysetId)) {
|
||||
continue;
|
||||
}
|
||||
final status = DeadlineStatus.of(
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.d
|
|||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
|
|
@ -107,6 +106,15 @@ final class const StudysetListEventUnarchiveRequested(
|
|||
List<Object?> get props => [id];
|
||||
}
|
||||
|
||||
/// Bring the studyset [id] back from Recently deleted.
|
||||
final class const StudysetListEventRestoreRequested(
|
||||
/// The studyset's id.
|
||||
final String id,
|
||||
) extends StudysetListEvent {
|
||||
@override
|
||||
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(
|
||||
|
|
@ -153,19 +161,24 @@ final class const StudysetListStateLoaded(
|
|||
/// studyset's own, with everything inside it; a studyset without one is
|
||||
/// still being worked out.
|
||||
final Map<String, StudyProgress> progress = const {},
|
||||
|
||||
/// In the library, the studysets waiting in Recently deleted, the most
|
||||
/// recently deleted first; inside a studyset none.
|
||||
final List<Studyset> deleted = const [],
|
||||
}) extends StudysetListState {
|
||||
@override
|
||||
List<Object?> get props => [studysets, progress];
|
||||
List<Object?> get props => [studysets, progress, deleted];
|
||||
}
|
||||
|
||||
/// The library: the person's studysets with the progress of their cards
|
||||
/// and how many notes and paths they hold, following every change, local or synced; creates, edits, stars, archives
|
||||
/// and deletes studysets. Given a [_parentId], it lists that studyset's
|
||||
/// children instead, for its topics, and creates studysets inside it.
|
||||
/// and how many notes and paths they hold, following every change, local or
|
||||
/// synced, and those waiting in Recently deleted; creates, edits, stars,
|
||||
/// archives and restores studysets. Given a [_parentId], it lists that
|
||||
/// studyset's children instead, for its topics, and creates studysets
|
||||
/// inside it.
|
||||
///
|
||||
/// Deleting goes to [StudysetDeletions] directly (see
|
||||
/// `deleteStudysetWithUndo`); the list leaves out what it reports as
|
||||
/// pending.
|
||||
/// Deleting goes to the person's `StudysetDeletions` directly (see
|
||||
/// `deleteStudysetWithUndo`): it outlives the screen it started on.
|
||||
class StudysetListBloc(
|
||||
final StudysetRepository _studysets,
|
||||
final CardRepository _cards,
|
||||
|
|
@ -173,7 +186,6 @@ class StudysetListBloc(
|
|||
final LearningPathRepository _paths,
|
||||
final PdfSourceRepository _pdfs,
|
||||
final ReviewRepository _reviews,
|
||||
final StudysetDeletions _deletions,
|
||||
|
||||
/// The person's settings, for the new cards a studyset without study
|
||||
/// options of its own starts a day.
|
||||
|
|
@ -200,19 +212,17 @@ class StudysetListBloc(
|
|||
if (state case StudysetListStateLoaded(
|
||||
:final studysets,
|
||||
:final progress,
|
||||
:final deleted,
|
||||
)) {
|
||||
emit(
|
||||
StudysetListStateLoaded(
|
||||
movedTo(studysets, (each) => each.id == event.id, event.toIndex),
|
||||
progress: progress,
|
||||
deleted: deleted,
|
||||
),
|
||||
);
|
||||
}
|
||||
return _studysets.reorder(
|
||||
event.id,
|
||||
toIndex: event.toIndex,
|
||||
hidden: _pendingDeletions,
|
||||
);
|
||||
return _studysets.reorder(event.id, toIndex: event.toIndex);
|
||||
});
|
||||
on<StudysetListEventContentChanged>(
|
||||
(event, emit) => _studysets.updateContent(event.id, event.content),
|
||||
|
|
@ -228,6 +238,9 @@ class StudysetListBloc(
|
|||
on<StudysetListEventUnarchiveRequested>(
|
||||
(event, emit) => _studysets.unarchive(event.id),
|
||||
);
|
||||
on<StudysetListEventRestoreRequested>(
|
||||
(event, emit) => _studysets.restore(event.id),
|
||||
);
|
||||
on<StudysetListEventProgressRefreshRequested>(_onProgressRefreshRequested);
|
||||
on<StudysetListEventRetried>((event, emit) => _studysets.reread());
|
||||
on<StudysetListEventResetRequested>((event, emit) async {
|
||||
|
|
@ -266,17 +279,22 @@ class StudysetListBloc(
|
|||
/// The person's studysets as last read.
|
||||
var _studysetsRead = const <Studyset>[];
|
||||
|
||||
/// The studysets whose deletion waits.
|
||||
var _pendingDeletions = const <String>{};
|
||||
/// In the library, the studysets waiting in Recently deleted, as last
|
||||
/// read.
|
||||
var _deletedRead = const <Studyset>[];
|
||||
|
||||
/// The studysets listed, in order, without those whose deletion waits.
|
||||
List<Studyset> get _shown => [
|
||||
for (final studyset in switch (_parentId) {
|
||||
null => _inLibraryOrder(_studysetsRead),
|
||||
final parentId => childrenOf(parentId, _studysetsRead),
|
||||
})
|
||||
if (!_pendingDeletions.contains(studyset.id)) studyset,
|
||||
];
|
||||
/// The studysets listed, in order.
|
||||
List<Studyset> get _shown => switch (_parentId) {
|
||||
null => _inLibraryOrder(_studysetsRead),
|
||||
final parentId => childrenOf(parentId, _studysetsRead),
|
||||
};
|
||||
|
||||
/// The list as last read, with the progress last worked out.
|
||||
StudysetListStateLoaded get _loaded => StudysetListStateLoaded(
|
||||
_shown,
|
||||
progress: _progress,
|
||||
deleted: _deletedRead,
|
||||
);
|
||||
|
||||
/// The last progress worked out.
|
||||
Map<String, StudyProgress> get _progress => switch (state) {
|
||||
|
|
@ -294,10 +312,21 @@ class StudysetListBloc(
|
|||
_studysetsRead = studysets;
|
||||
add(const StudysetListEventProgressRefreshRequested());
|
||||
// Until the new progress is worked out, the last one stands.
|
||||
return StudysetListStateLoaded(_shown, progress: _progress);
|
||||
return _loaded;
|
||||
},
|
||||
onError: (_, _) => const StudysetListStateFailed(),
|
||||
),
|
||||
if (_parentId == null)
|
||||
emit.onEach(
|
||||
_studysets.watchDeleted(),
|
||||
onData: (deleted) {
|
||||
_deletedRead = deleted;
|
||||
if (state is StudysetListStateLoaded) {
|
||||
emit(_loaded);
|
||||
}
|
||||
},
|
||||
onError: (_, _) {},
|
||||
),
|
||||
// Changed cards and notes, here or on another device, change the
|
||||
// progress too.
|
||||
emit.onEach(
|
||||
|
|
@ -323,15 +352,6 @@ class StudysetListBloc(
|
|||
onData: (_) => add(const StudysetListEventProgressRefreshRequested()),
|
||||
onError: (_, _) {},
|
||||
),
|
||||
emit.onEach(
|
||||
_deletions.watchPending(),
|
||||
onData: (pending) {
|
||||
_pendingDeletions = pending;
|
||||
if (state is StudysetListStateLoaded) {
|
||||
emit(StudysetListStateLoaded(_shown, progress: _progress));
|
||||
}
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
/// Works the progress out, one at a time: requests while it runs (six
|
||||
|
|
@ -429,8 +449,17 @@ class StudysetListBloc(
|
|||
log('Working out the progress failed', name: 'Library', error: error);
|
||||
return;
|
||||
}
|
||||
if (state case StudysetListStateLoaded(:final studysets)) {
|
||||
emit(StudysetListStateLoaded(studysets, progress: progress));
|
||||
if (state case StudysetListStateLoaded(
|
||||
:final studysets,
|
||||
:final deleted,
|
||||
)) {
|
||||
emit(
|
||||
StudysetListStateLoaded(
|
||||
studysets,
|
||||
progress: progress,
|
||||
deleted: deleted,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,45 +1,100 @@
|
|||
import 'package:halkyon_learn_flutter/src/core/async/delayed_deletions.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
|
||||
/// The person's studyset deletions, which can be undone for a moment (see
|
||||
/// [DelayedDeletions]). Once the time to undo is over, the studysets of the
|
||||
/// deleted one's subtree are blanked and deleted with their cards, notes,
|
||||
/// learning paths and PDFs.
|
||||
/// What becomes of the person's deleted studysets (decided 2026-10-12, as
|
||||
/// Vaia has it). Deleting one marks it ([delete]): it waits in Recently
|
||||
/// deleted for [Studyset.keptWhenDeleted] with everything inside it, out of
|
||||
/// every other list
|
||||
/// and of studying, and can be restored until then ([restore]). After that,
|
||||
/// whichever of the person's devices looks next deletes it for good
|
||||
/// ([deleteExpired]: as the person's data opens and after every sync),
|
||||
/// and Delete now does at once ([deleteForGood]). Deleting for good blanks
|
||||
/// and deletes the studysets of its subtree, what is inside first, each with
|
||||
/// its cards, notes, learning paths and PDFs, since blanking can't be undone
|
||||
/// (ADR 0002, rule 2).
|
||||
///
|
||||
/// The server can't do it: it would have to blank synced payloads, which
|
||||
/// the sync engine stores wrongly when the server writes them (see
|
||||
/// AGENTS.md), and it never writes into a person's space. So a studyset
|
||||
/// deleted by someone who never opens the app again stays deleted, not
|
||||
/// gone, as the rest of what they left does.
|
||||
class StudysetDeletions(
|
||||
StudysetRepository studysets,
|
||||
CardRepository cards,
|
||||
NoteRepository notes,
|
||||
LearningPathRepository paths,
|
||||
PdfSourceRepository pdfs, {
|
||||
super.undoWindow,
|
||||
}) extends DelayedDeletions {
|
||||
this
|
||||
: super(
|
||||
(id) => _deleteSubtree(id, studysets, cards, notes, paths, pdfs),
|
||||
logName: 'Studysets',
|
||||
);
|
||||
}
|
||||
final StudysetRepository _studysets,
|
||||
final CardRepository _cards,
|
||||
final NoteRepository _notes,
|
||||
final LearningPathRepository _paths,
|
||||
final PdfSourceRepository _pdfs,
|
||||
) {
|
||||
// One deletion for good at a time: two would both delete what is inside
|
||||
// a studyset that is inside the other's.
|
||||
final _queue = SerialQueue();
|
||||
StreamSubscription<void>? _checks;
|
||||
|
||||
Future<void> _deleteSubtree(
|
||||
String id,
|
||||
StudysetRepository studysets,
|
||||
CardRepository cards,
|
||||
NoteRepository notes,
|
||||
LearningPathRepository paths,
|
||||
PdfSourceRepository pdfs,
|
||||
) async {
|
||||
// What is inside first, so no studyset is ever left without its parent;
|
||||
// each studyset's content before it, so none outlives its studyset.
|
||||
for (final studysetId in subtreeOf(id, await studysets.all()).reversed) {
|
||||
await cards.deleteAllOf(studysetId);
|
||||
await notes.deleteAllOf(studysetId);
|
||||
await paths.deleteAllOf(studysetId);
|
||||
await pdfs.deleteAllOf(studysetId);
|
||||
await studysets.delete(studysetId);
|
||||
/// Deletes the studyset [id], with everything inside it: it waits in
|
||||
/// Recently deleted for [Studyset.keptWhenDeleted].
|
||||
Future<void> delete(String id) => _studysets.markDeleted(id);
|
||||
|
||||
/// Brings the studyset [id] back from Recently deleted.
|
||||
Future<void> restore(String id) => _studysets.restore(id);
|
||||
|
||||
/// Deletes the studyset [id] for good, with everything inside it.
|
||||
Future<void> deleteForGood(String id) => _queue.run(() => _deleteSubtree(id));
|
||||
|
||||
/// Deletes for good the studysets whose time in Recently deleted is over,
|
||||
/// now and whenever [checks] fires (a sync that went through, which may have
|
||||
/// brought deletions from another device).
|
||||
void deleteExpiredOn(Stream<void> checks) {
|
||||
unawaited(deleteExpired());
|
||||
_checks = checks.listen((_) => unawaited(deleteExpired()));
|
||||
}
|
||||
|
||||
/// Deletes for good the studysets whose time in Recently deleted is over.
|
||||
Future<void> deleteExpired() => _queue.run(() async {
|
||||
try {
|
||||
final now = clock.now();
|
||||
for (final studyset in recentlyDeleted(await _studysets.withDeleted())) {
|
||||
if (!studyset.goesAt!.isAfter(now)) {
|
||||
await _deleteSubtree(studyset.id);
|
||||
}
|
||||
}
|
||||
} on Object catch (error) {
|
||||
// The next check tries again.
|
||||
log(
|
||||
'Deleting expired studysets failed',
|
||||
name: 'Studysets',
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/// Stops checking, and waits for a deletion under way to finish.
|
||||
Future<void> dispose() async {
|
||||
await _checks?.cancel();
|
||||
await _queue.run(() async {});
|
||||
}
|
||||
|
||||
Future<void> _deleteSubtree(String id) async {
|
||||
// What is inside first, so no studyset is ever left without its parent;
|
||||
// each studyset's content before it, so none outlives its studyset.
|
||||
for (final studysetId in subtreeOf(
|
||||
id,
|
||||
await _studysets.withDeleted(),
|
||||
).reversed) {
|
||||
await _cards.deleteAllOf(studysetId);
|
||||
await _notes.deleteAllOf(studysetId);
|
||||
await _paths.deleteAllOf(studysetId);
|
||||
await _pdfs.deleteAllOf(studysetId);
|
||||
await _studysets.delete(studysetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ typedef StudysetRow = ({
|
|||
String? parentId,
|
||||
String position,
|
||||
DateTime? archivedAt,
|
||||
DateTime? deletedAt,
|
||||
DateTime? starredAt,
|
||||
DateTime? updatedAt,
|
||||
});
|
||||
|
|
@ -61,6 +62,14 @@ abstract interface class StudysetGateway() {
|
|||
DateTime updatedAt,
|
||||
);
|
||||
|
||||
/// Sets or clears when a studyset was deleted, as of [updatedAt]: it
|
||||
/// waits in Recently deleted until then.
|
||||
Future<void> updateDeletedAt(
|
||||
String id,
|
||||
DateTime? deletedAt,
|
||||
DateTime updatedAt,
|
||||
);
|
||||
|
||||
/// Deletes a studyset's row.
|
||||
Future<void> delete(String id);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_conten
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
|
||||
/// The person's studysets: read from this device, changed offline, and synced
|
||||
/// with their other devices in the background.
|
||||
/// with their other devices in the background. What is deleted waits in
|
||||
/// Recently deleted ([watchDeleted]), out of every other list ([all],
|
||||
/// [watchAll]) with everything inside it, until it is restored or deleted
|
||||
/// for good.
|
||||
class StudysetRepository(final StudysetGateway _gateway) {
|
||||
this {
|
||||
_remoteChanges = _gateway.remoteChanges.listen(
|
||||
|
|
@ -20,15 +23,25 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
|||
);
|
||||
}
|
||||
|
||||
/// Every studyset, those deleted too, after every change.
|
||||
final _studysets = StreamController<List<Studyset>>.broadcast();
|
||||
late final StreamSubscription<void> _remoteChanges;
|
||||
Future<void> _lastRefresh = Future.value();
|
||||
|
||||
/// All studysets, now and after every change, local or synced. A read that
|
||||
/// fails arrives as an error; the next change reads again.
|
||||
Stream<List<Studyset>> watchAll() => Stream.multi((listener) {
|
||||
/// All studysets but those deleted and what is inside them ([all]), now
|
||||
/// and after every change, local or synced. A read that fails arrives as
|
||||
/// an error; the next change reads again.
|
||||
Stream<List<Studyset>> watchAll() => _watch(notDeleted);
|
||||
|
||||
/// The studysets waiting in Recently deleted ([recentlyDeleted]), now and
|
||||
/// after every change.
|
||||
Stream<List<Studyset>> watchDeleted() => _watch(recentlyDeleted);
|
||||
|
||||
Stream<List<Studyset>> _watch(
|
||||
List<Studyset> Function(List<Studyset> all) shown,
|
||||
) => Stream.multi((listener) {
|
||||
final subscription = _studysets.stream.listen(
|
||||
listener.add,
|
||||
(all) => listener.add(shown(all)),
|
||||
onError: listener.addError,
|
||||
);
|
||||
listener.onCancel = subscription.cancel;
|
||||
|
|
@ -39,9 +52,13 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
|||
/// after a change: to try once more after a read failed.
|
||||
Future<void> reread() => _refresh();
|
||||
|
||||
/// The studysets on this device right now, read once; [watchAll] follows
|
||||
/// every change.
|
||||
Future<List<Studyset>> all() async => [
|
||||
/// The studysets on this device right now, read once, but those deleted
|
||||
/// and what is inside them; [watchAll] follows every change.
|
||||
Future<List<Studyset>> all() async => notDeleted(await withDeleted());
|
||||
|
||||
/// Every studyset on this device right now, those deleted and inside
|
||||
/// them too.
|
||||
Future<List<Studyset>> withDeleted() async => [
|
||||
for (final row in await _gateway.all()) ?_decode(row),
|
||||
];
|
||||
|
||||
|
|
@ -78,15 +95,10 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
|||
}
|
||||
|
||||
/// Moves the studyset [id] to [toIndex] among its siblings the list
|
||||
/// shows: those [hidden] (their deletion can still be undone) left out,
|
||||
/// as the list leaves them out. Siblings from before studysets had an
|
||||
/// order share one position; a run of them it goes into gets positions of
|
||||
/// its own ([placeAmong]).
|
||||
Future<void> reorder(
|
||||
String id, {
|
||||
required int toIndex,
|
||||
Set<String> hidden = const {},
|
||||
}) async {
|
||||
/// shows, those deleted left out as the list leaves them out. Siblings
|
||||
/// from before studysets had an order share one position; a run of them it
|
||||
/// goes into gets positions of its own ([placeAmong]).
|
||||
Future<void> reorder(String id, {required int toIndex}) async {
|
||||
final studysets = await all();
|
||||
final studyset = studysets.where((each) => each.id == id).firstOrNull;
|
||||
if (studyset == null) {
|
||||
|
|
@ -94,8 +106,7 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
|||
}
|
||||
final others = [
|
||||
for (final sibling in await _siblings(studyset.parentId, studysets))
|
||||
if (sibling.id != id && !hidden.contains(sibling.id))
|
||||
(id: sibling.id, position: sibling.position),
|
||||
if (sibling.id != id) (id: sibling.id, position: sibling.position),
|
||||
];
|
||||
final now = _now();
|
||||
for (final MapEntry(key: moved, value: position) in placeAmong(
|
||||
|
|
@ -140,8 +151,25 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
|||
await _refresh();
|
||||
}
|
||||
|
||||
/// Deletes a studyset. Its content is blanked first: the sync engine keeps
|
||||
/// deleted rows until the whole space is deleted (ADR 0002, rule 2).
|
||||
/// Deletes the studyset [id], and so everything inside it: it waits in
|
||||
/// Recently deleted, out of every other list, until it is restored or
|
||||
/// deleted for good ([delete]).
|
||||
Future<void> markDeleted(String id) async {
|
||||
final now = _now();
|
||||
await _gateway.updateDeletedAt(id, now, now);
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
/// Brings the studyset [id] back from Recently deleted, with everything
|
||||
/// inside it.
|
||||
Future<void> restore(String id) async {
|
||||
await _gateway.updateDeletedAt(id, null, _now());
|
||||
await _refresh();
|
||||
}
|
||||
|
||||
/// Deletes a studyset's row for good. Its content is blanked first: the
|
||||
/// sync engine keeps deleted rows until the whole space is deleted (ADR
|
||||
/// 0002, rule 2).
|
||||
Future<void> delete(String id) async {
|
||||
await _gateway.updatePayload(id, redactedPayload, _now());
|
||||
await _gateway.delete(id);
|
||||
|
|
@ -162,7 +190,7 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
|||
return;
|
||||
}
|
||||
try {
|
||||
final studysets = await all();
|
||||
final studysets = await withDeleted();
|
||||
if (!_studysets.isClosed) {
|
||||
_studysets.add(studysets);
|
||||
}
|
||||
|
|
@ -184,6 +212,7 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
|||
parentId: row.parentId,
|
||||
position: row.position,
|
||||
archivedAt: row.archivedAt,
|
||||
deletedAt: row.deletedAt,
|
||||
starredAt: row.starredAt,
|
||||
updatedAt: row.updatedAt,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ class SyncStudysetGateway(
|
|||
parentId: studyset.parentId?.uuid,
|
||||
position: studyset.position,
|
||||
archivedAt: studyset.archivedAt,
|
||||
deletedAt: studyset.deletedAt,
|
||||
starredAt: studyset.starredAt,
|
||||
updatedAt: studyset.updatedAt,
|
||||
),
|
||||
|
|
@ -135,6 +136,22 @@ class SyncStudysetGateway(
|
|||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateDeletedAt(
|
||||
String id,
|
||||
DateTime? deletedAt,
|
||||
DateTime updatedAt,
|
||||
) async {
|
||||
if (await _find(id) case final studyset?) {
|
||||
await protocol.Studyset.db.updateRow(
|
||||
_db,
|
||||
studyset.copyWith(deletedAt: deletedAt, updatedAt: updatedAt),
|
||||
columns: (t) => [t.deletedAt, t.updatedAt],
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String id) async {
|
||||
await protocol.Studyset.db.deleteWhere(
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ final class const Studyset({
|
|||
/// When the person archived it, in UTC; null while it is active.
|
||||
final DateTime? archivedAt,
|
||||
|
||||
/// When the person deleted it, in UTC; null while it isn't. It waits in
|
||||
/// Recently deleted for [keptWhenDeleted], then goes for good.
|
||||
final DateTime? deletedAt,
|
||||
|
||||
/// When the person starred it, in UTC; null when it isn't starred.
|
||||
final DateTime? starredAt,
|
||||
|
||||
|
|
@ -29,6 +33,17 @@ final class const Studyset({
|
|||
/// in the archive too.
|
||||
bool get isArchived => archivedAt != null;
|
||||
|
||||
/// How long a deleted studyset waits in Recently deleted, restorable
|
||||
/// (decided 2026-10-12, as Vaia keeps them).
|
||||
static const keptWhenDeleted = Duration(days: 14);
|
||||
|
||||
/// Whether it was deleted itself, waiting in Recently deleted; a studyset
|
||||
/// inside a deleted one waits with it.
|
||||
bool get isDeleted => deletedAt != null;
|
||||
|
||||
/// When it goes for good, once deleted; null while it isn't.
|
||||
DateTime? get goesAt => deletedAt?.add(keptWhenDeleted);
|
||||
|
||||
/// Whether it is starred, to stay at the top of the library.
|
||||
bool get isStarred => starredAt != null;
|
||||
|
||||
|
|
@ -39,6 +54,7 @@ final class const Studyset({
|
|||
parentId,
|
||||
position,
|
||||
archivedAt,
|
||||
deletedAt,
|
||||
starredAt,
|
||||
updatedAt,
|
||||
];
|
||||
|
|
@ -50,6 +66,7 @@ final class const Studyset({
|
|||
parentId: parentId,
|
||||
position: position,
|
||||
archivedAt: archivedAt,
|
||||
deletedAt: deletedAt,
|
||||
starredAt: starredAt,
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -80,6 +80,30 @@ List<String> studiedIn(String id, List<Studyset> all) {
|
|||
return studied;
|
||||
}
|
||||
|
||||
/// The studysets of [all] neither deleted nor inside a deleted one (decided
|
||||
/// 2026-10-12): what every list, count and session sees, while the deleted
|
||||
/// wait in Recently deleted.
|
||||
List<Studyset> notDeleted(List<Studyset> all) {
|
||||
final waiting = {
|
||||
for (final studyset in all)
|
||||
if (studyset.isDeleted) ...subtreeOf(studyset.id, all),
|
||||
};
|
||||
return [
|
||||
for (final studyset in all)
|
||||
if (!waiting.contains(studyset.id)) studyset,
|
||||
];
|
||||
}
|
||||
|
||||
/// The studysets of [all] waiting in Recently deleted: those deleted
|
||||
/// themselves and not inside another deleted one, which brings them back
|
||||
/// when it is restored; the most recently deleted first.
|
||||
List<Studyset> recentlyDeleted(List<Studyset> all) => [
|
||||
for (final studyset in all)
|
||||
if (studyset.isDeleted &&
|
||||
!ancestorsOf(studyset.id, all).any((ancestor) => ancestor.isDeleted))
|
||||
studyset,
|
||||
]..sort((a, b) => b.deletedAt!.compareTo(a.deletedAt!));
|
||||
|
||||
/// The studysets the studyset [id] sits in, from the one showing at the top
|
||||
/// level down to its parent; empty for a studyset at the top level itself.
|
||||
List<Studyset> ancestorsOf(String id, List<Studyset> all) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
|
||||
|
|
@ -5,37 +7,47 @@ import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Asks before deleting [studyset] with everything inside it, then hands the
|
||||
/// deletion to the person's `StudysetDeletions` and offers to undo it for a
|
||||
/// moment; [onDeleted] runs once the person has confirmed, as to close the
|
||||
/// deleted studyset's own screen.
|
||||
/// Deletes [studyset] with everything inside it, which then waits in the
|
||||
/// library's Recently deleted, and offers to undo that for a moment;
|
||||
/// [onDeleted] runs at once, as to close the deleted studyset's own screen.
|
||||
/// It doesn't ask first (decided 2026-10-12): it can be restored for 14
|
||||
/// days, as archiving can be undone.
|
||||
///
|
||||
/// Deleting and undoing go to the deletions directly rather than through a
|
||||
/// bloc: the deletion outlives the screen it started on, and so must its
|
||||
/// Undo.
|
||||
Future<void> deleteStudysetWithUndo(
|
||||
/// Deleting and undoing go to the person's `StudysetDeletions` directly
|
||||
/// rather than through a bloc: the deletion outlives the screen it started
|
||||
/// on, and so must its Undo.
|
||||
void deleteStudysetWithUndo(
|
||||
BuildContext context,
|
||||
Studyset studyset, {
|
||||
VoidCallback? onDeleted,
|
||||
}) async {
|
||||
}) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final deletions = PersonScope.of(context).deletions;
|
||||
if (!await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.libraryDeleteTitle,
|
||||
message: l10n.libraryDeleteMessage,
|
||||
confirmLabel: l10n.libraryDeleteConfirm,
|
||||
) ||
|
||||
!context.mounted) {
|
||||
return;
|
||||
}
|
||||
deletions.delete(studyset.id);
|
||||
unawaited(deletions.delete(studyset.id));
|
||||
// Shown before [onDeleted] closes anything: the message belongs to the
|
||||
// whole app and stays while screens close.
|
||||
showUndoSnackBar(
|
||||
context,
|
||||
message: l10n.libraryStudysetDeleted,
|
||||
onUndo: () => deletions.undo(studyset.id),
|
||||
onUndo: () => unawaited(deletions.restore(studyset.id)),
|
||||
);
|
||||
onDeleted?.call();
|
||||
}
|
||||
|
||||
/// Asks before deleting [studyset], waiting in Recently deleted, for good
|
||||
/// with everything inside it, which can't be undone; then does.
|
||||
Future<void> deleteStudysetForGood(
|
||||
BuildContext context,
|
||||
Studyset studyset,
|
||||
) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final deletions = PersonScope.of(context).deletions;
|
||||
if (await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.libraryDeleteForGoodTitle,
|
||||
message: l10n.libraryDeleteForGoodMessage(studyset.content.title),
|
||||
confirmLabel: l10n.libraryDeleteForGoodConfirm,
|
||||
)) {
|
||||
await deletions.deleteForGood(studyset.id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A studyset waiting in Recently deleted: shaped as the library's other
|
||||
/// cards, with a neutral bar, its title with the secondary title as a
|
||||
/// kicker, and at its foot how long until it goes for good, Restore, and in
|
||||
/// its options Delete now. What is deleted doesn't open: a tap does nothing.
|
||||
class const DeletedStudysetCard({
|
||||
/// The studyset, deleted.
|
||||
required final Studyset studyset,
|
||||
|
||||
/// Brings it back.
|
||||
required final VoidCallback onRestore,
|
||||
|
||||
/// Deletes it for good, after asking.
|
||||
required final VoidCallback onDeleteNow,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
/// The days until [studyset] goes for good, as of [now], a day begun
|
||||
/// counting whole: 14 as it is deleted, 1 within its last day, 0 once its
|
||||
/// time is over and it waits for a device to delete it.
|
||||
static int daysLeft(Studyset studyset, DateTime now) =>
|
||||
switch (studyset.goesAt) {
|
||||
final goes? when goes.isAfter(now) =>
|
||||
(goes.difference(now).inMinutes / Duration.minutesPerDay).ceil(),
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(height: 4, color: colors.outlineVariant),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Opacity(
|
||||
opacity: 0.6,
|
||||
child: TitleWithKicker(
|
||||
title: studyset.content.title,
|
||||
kicker: studyset.content.secondaryTitle,
|
||||
titleStyle: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
reserveKickerSpace: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, thickness: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 8, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.hourglass,
|
||||
size: 16,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.libraryDeletedDaysLeft(
|
||||
daysLeft(studyset, clock.now()),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onRestore,
|
||||
child: Text(l10n.libraryRestore),
|
||||
),
|
||||
PopupMenuButton<_Option>(
|
||||
icon: const Icon(LucideIcons.ellipsis),
|
||||
tooltip: l10n.studysetOptions,
|
||||
onSelected: (option) => switch (option) {
|
||||
_Option.deleteNow => onDeleteNow(),
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: _Option.deleteNow,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.trash2,
|
||||
size: 18,
|
||||
color: colors.error,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
l10n.libraryDeleteNow,
|
||||
style: TextStyle(color: colors.error),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What a deleted studyset's options offer.
|
||||
enum _Option() {
|
||||
deleteNow,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue