From da333f4a093fc519dc42d80039d23e5cc58d0b8b Mon Sep 17 00:00:00 2001 From: Hampus Hammarlund Date: Sat, 31 Jan 2026 22:22:22 +0900 Subject: [PATCH 1/2] feat: add option to space out flashcards on demand --- lib/services/dictionary_service.dart | 20 +++++++++ .../flashcard_set_settings_view.dart | 7 ++- .../flashcard_set_settings_viewmodel.dart | 43 +++++++++++++++++++ .../flashcards/flashcards_viewmodel.dart | 16 +------ 4 files changed, 70 insertions(+), 16 deletions(-) diff --git a/lib/services/dictionary_service.dart b/lib/services/dictionary_service.dart index 98b6b44..60426a4 100644 --- a/lib/services/dictionary_service.dart +++ b/lib/services/dictionary_service.dart @@ -11,6 +11,7 @@ import 'package:archive/archive_io.dart' as archive; import 'package:sagase/datamodels/user_backup.dart'; import 'package:sagase/services/isar_service.dart'; import 'package:sagase/utils/constants.dart' as constants; +import 'package:sagase/utils/date_time_utils.dart'; import 'package:sagase_dictionary/sagase_dictionary.dart'; import 'package:path/path.dart' as path; @@ -465,6 +466,25 @@ class DictionaryService { ); } + Future spaceOutFlashcards(List flashcards) async { + await _database.transaction(() async { + final now = DateTime.now(); + + int flashcardsPerDay = (flashcards.length - 150) ~/ 12; + for (int i = 1; i < 14; i++) { + int dueDate = now.add(Duration(days: i)).toInt(); + for (int j = 0; j < flashcardsPerDay && flashcards.length > 150; j++) { + await setSpacedRepetitionData( + flashcards + .removeLast() + .spacedRepetitionData! + .copyWith(dueDate: dueDate), + ); + } + } + }); + } + Future setSpacedRepetitionData(SpacedRepetitionData data) async { return _database.spacedRepetitionDatasDao.set(data); } diff --git a/lib/ui/views/flashcard_set_settings/flashcard_set_settings_view.dart b/lib/ui/views/flashcard_set_settings/flashcard_set_settings_view.dart index 27611ba..d4c50a6 100644 --- a/lib/ui/views/flashcard_set_settings/flashcard_set_settings_view.dart +++ b/lib/ui/views/flashcard_set_settings/flashcard_set_settings_view.dart @@ -66,13 +66,18 @@ class FlashcardSetSettingsView if (flashcardSet.usingSpacedRepetition) const PopupMenuItem( value: PopupMenuItemType.reset, - child: Text('Reset'), + child: Text('Reset progress'), ), if (flashcardSet.usingSpacedRepetition) const PopupMenuItem( value: PopupMenuItemType.statistics, child: Text('View statistics'), ), + if (flashcardSet.usingSpacedRepetition) + const PopupMenuItem( + value: PopupMenuItemType.spaceOut, + child: Text('Space out flashcards'), + ), ], onSelected: viewModel.handlePopupMenuButton, ), diff --git a/lib/ui/views/flashcard_set_settings/flashcard_set_settings_viewmodel.dart b/lib/ui/views/flashcard_set_settings/flashcard_set_settings_viewmodel.dart index 6e132d3..1af08e0 100644 --- a/lib/ui/views/flashcard_set_settings/flashcard_set_settings_viewmodel.dart +++ b/lib/ui/views/flashcard_set_settings/flashcard_set_settings_viewmodel.dart @@ -4,6 +4,7 @@ import 'package:sagase/app/app.locator.dart'; import 'package:sagase/app/app.router.dart'; import 'package:sagase/datamodels/lists_bottom_sheet_argument.dart'; import 'package:sagase/services/shared_preferences_service.dart'; +import 'package:sagase/utils/date_time_utils.dart'; import 'package:sagase_dictionary/sagase_dictionary.dart'; import 'package:sagase/datamodels/my_lists_bottom_sheet_item.dart'; import 'package:sagase/services/dictionary_service.dart'; @@ -50,6 +51,9 @@ class FlashcardSetSettingsViewModel extends FutureViewModel { case PopupMenuItemType.statistics: _openFlashcardSetInfo(); break; + case PopupMenuItemType.spaceOut: + _spaceOutDueFlashcards(); + break; } } @@ -245,6 +249,44 @@ class FlashcardSetSettingsViewModel extends FutureViewModel { bool shouldShowTutorial() { return _sharedPreferencesService.getAndSetTutorialFlashcardSetSettings(); } + + Future _spaceOutDueFlashcards() async { + final response = await _dialogService.showCustomDialog( + variant: DialogType.confirmation, + title: 'Reduce due flashcards?', + description: + 'Have too many due flashcards? To help you study due flashcards can be delayed. Pressing confirm will leave 150 due flashcards for today and spread the rest out over the next 2 weeks.', + mainButtonTitle: 'Confirm', + secondaryButtonTitle: 'Cancel', + barrierDismissible: true, + ); + + if (response != null && response.confirmed) { + _dialogService.showCustomDialog( + variant: DialogType.progressIndicator, + title: 'Updating flashcards', + barrierDismissible: false, + ); + + final allFlashcards = + await _dictionaryService.getFlashcardSetFlashcards(flashcardSet); + final dueFlashcards = []; + + final sessionDateTime = DateTime.now(); + int todayAsInt = sessionDateTime.toInt(); + for (var item in allFlashcards) { + if (item.spacedRepetitionData != null && + item.spacedRepetitionData!.dueDate! <= todayAsInt) { + dueFlashcards.add(item); + } + } + + dueFlashcards.shuffle(); + await _dictionaryService.spaceOutFlashcards(dueFlashcards); + + _dialogService.completeDialog(DialogResponse()); + } + } } enum PopupMenuItemType { @@ -252,4 +294,5 @@ enum PopupMenuItemType { delete, reset, statistics, + spaceOut, } diff --git a/lib/ui/views/flashcards/flashcards_viewmodel.dart b/lib/ui/views/flashcards/flashcards_viewmodel.dart index 6bbbf93..8feb12b 100644 --- a/lib/ui/views/flashcards/flashcards_viewmodel.dart +++ b/lib/ui/views/flashcards/flashcards_viewmodel.dart @@ -716,7 +716,6 @@ class FlashcardsViewModel extends FutureViewModel { ); if (response != null && response.confirmed) { - // Show progress indicator dialog _dialogService.showCustomDialog( variant: DialogType.progressIndicator, title: 'Updating flashcards', @@ -724,20 +723,7 @@ class FlashcardsViewModel extends FutureViewModel { ); dueFlashcards.shuffle(_random); - int flashcardsPerDay = (dueFlashcards.length - 150) ~/ 12; - for (int i = 1; i < 14; i++) { - int dueDate = sessionDateTime.add(Duration(days: i)).toInt(); - for (int j = 0; - j < flashcardsPerDay && dueFlashcards.length > 150; - j++) { - await _dictionaryService.setSpacedRepetitionData( - dueFlashcards - .removeLast() - .spacedRepetitionData! - .copyWith(dueDate: dueDate), - ); - } - } + await _dictionaryService.spaceOutFlashcards(dueFlashcards); _dialogService.completeDialog(DialogResponse()); } From a9cab7289bdcbb3641cba9161c6c78a57cefb490 Mon Sep 17 00:00:00 2001 From: Hampus Hammarlund Date: Sun, 1 Feb 2026 00:04:57 +0900 Subject: [PATCH 2/2] feat: add option to add new flashcards in batches --- lib/services/shared_preferences_service.dart | 10 ++++++ .../flashcards/flashcards_viewmodel.dart | 23 ++++++++++--- lib/ui/views/settings/settings_view.dart | 10 ++++++ lib/ui/views/settings/settings_viewmodel.dart | 7 ++++ lib/utils/constants.dart | 2 ++ test/helpers/mocks.dart | 4 +++ .../flashcards/flashcards_viewmodel_test.dart | 32 +++++++++++++++++++ 7 files changed, 84 insertions(+), 4 deletions(-) diff --git a/lib/services/shared_preferences_service.dart b/lib/services/shared_preferences_service.dart index 60cc388..b443927 100644 --- a/lib/services/shared_preferences_service.dart +++ b/lib/services/shared_preferences_service.dart @@ -237,4 +237,14 @@ class SharedPreferencesService implements InitializableDependency { Future setProperNounsEnabled(bool value) async { await _sharedPreferences.setBool(constants.keyProperNounsEnabled, value); } + + bool getAddNewFlashcardsInBatches() { + return _sharedPreferences.getBool(constants.keyAddNewFlashcardsInBatches) ?? + constants.defaultAddNewFlashcardsInBatches; + } + + Future setAddNewFlashcardsInBatches(bool value) async { + await _sharedPreferences.setBool( + constants.keyAddNewFlashcardsInBatches, value); + } } diff --git a/lib/ui/views/flashcards/flashcards_viewmodel.dart b/lib/ui/views/flashcards/flashcards_viewmodel.dart index 8feb12b..8983994 100644 --- a/lib/ui/views/flashcards/flashcards_viewmodel.dart +++ b/lib/ui/views/flashcards/flashcards_viewmodel.dart @@ -344,11 +344,26 @@ class FlashcardsViewModel extends FutureViewModel { // If active flashcards is still empty then try to add new flashcards Future?>? reportDialogResponse; if (activeFlashcards.isEmpty) { - activeFlashcards.addAll(newFlashcards); - newFlashcards.clear(); + if (_sharedPreferencesService.getFlashcardLearningModeEnabled() && + _sharedPreferencesService.getAddNewFlashcardsInBatches()) { + if (initial) newFlashcards.shuffle(_random); + + int newFlashcardsToAdd = max( + 0, + min( + _sharedPreferencesService.getNewFlashcardsPerDay() - + startedFlashcards.length, + newFlashcards.length, + )); + activeFlashcards.addAll(newFlashcards.take(newFlashcardsToAdd)); + newFlashcards.removeRange(0, newFlashcardsToAdd); + } else { + activeFlashcards.addAll(newFlashcards); + newFlashcards.clear(); + activeFlashcards.shuffle(_random); + } _answeringDueFlashcards = false; - // Randomize - activeFlashcards.shuffle(_random); + // Add any started flashcards activeFlashcards.insertAll(0, startedFlashcards..shuffle(_random)); startedFlashcards.clear(); diff --git a/lib/ui/views/settings/settings_view.dart b/lib/ui/views/settings/settings_view.dart index 524e532..54038bb 100644 --- a/lib/ui/views/settings/settings_view.dart +++ b/lib/ui/views/settings/settings_view.dart @@ -96,6 +96,16 @@ class SettingsView extends StatelessWidget { ), onPressed: (_) => viewModel.setNewFlashcardsPerDay(), ), + SettingsTile.switchTile( + enabled: viewModel.flashcardLearningModeEnabled, + initialValue: viewModel.addNewFlashcardsInBatches, + onToggle: viewModel.setAddNewFlashcardsInBatches, + activeSwitchColor: Theme.of(context).colorScheme.primary, + title: const Text('Add new flashcards in batches'), + description: const Text( + 'If enabled, when no due flashcards are available new flashcards are added in batches instead of all at once.', + ), + ), SettingsTile.navigation( title: const Text('Set initial spaced repetition interval'), diff --git a/lib/ui/views/settings/settings_viewmodel.dart b/lib/ui/views/settings/settings_viewmodel.dart index 8c45d6b..39f6cf1 100644 --- a/lib/ui/views/settings/settings_viewmodel.dart +++ b/lib/ui/views/settings/settings_viewmodel.dart @@ -31,6 +31,8 @@ class SettingsViewModel extends BaseViewModel { _sharedPreferencesService.getFlashcardLearningModeEnabled(); int get newFlashcardsPerDay => _sharedPreferencesService.getNewFlashcardsPerDay(); + bool get addNewFlashcardsInBatches => + _sharedPreferencesService.getAddNewFlashcardsInBatches(); int get flashcardDistance => _sharedPreferencesService.getFlashcardDistance(); int get flashcardCorrectAnswersRequired => _sharedPreferencesService.getFlashcardCorrectAnswersRequired(); @@ -107,6 +109,11 @@ class SettingsViewModel extends BaseViewModel { } catch (_) {} } + void setAddNewFlashcardsInBatches(bool value) { + _sharedPreferencesService.setAddNewFlashcardsInBatches(value); + notifyListeners(); + } + Future setFlashcardDistance() async { final response = await _dialogService.showCustomDialog( variant: DialogType.numberTextField, diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index cee02d3..7c8ab4b 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -25,6 +25,7 @@ const keyTutorialVocab = 'tutorial_vocab'; const keyShowDetailedProgress = 'show_detailed_progress'; const keyChangelogVersionShown = 'changelog_version_shown'; const keyProperNounsEnabled = 'proper_nouns_enabled'; +const keyAddNewFlashcardsInBatches = 'add_new_flashcards_in_batches'; const defaultInitialCorrectInterval = 1; const defaultInitialVeryCorrectInterval = 4; @@ -40,6 +41,7 @@ const defaultStartOnLearningView = false; const defaultStrokeDiagramStartExpanded = true; const defaultShowDetailedProgress = false; const defaultProperNounsEnabled = false; +const defaultAddNewFlashcardsInBatches = false; const searchQueryLimit = 1000; diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index 54c41b8..c4fe416 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -164,6 +164,8 @@ MockSharedPreferencesService getAndRegisterSharedPreferencesService({ bool getShowDetailedProgress = constants.defaultShowDetailedProgress, bool getOnboardingFinished = false, bool getProperNounsEnabled = constants.defaultProperNounsEnabled, + bool getAddNewFlashcardsInBatches = + constants.defaultAddNewFlashcardsInBatches, }) { _removeRegistrationIfExists(); final service = MockSharedPreferencesService(); @@ -188,6 +190,8 @@ MockSharedPreferencesService getAndRegisterSharedPreferencesService({ when(service.getShowDetailedProgress()).thenReturn(getShowDetailedProgress); when(service.getOnboardingFinished()).thenReturn(getOnboardingFinished); when(service.getProperNounsEnabled()).thenReturn(getProperNounsEnabled); + when(service.getAddNewFlashcardsInBatches()) + .thenReturn(getAddNewFlashcardsInBatches); locator.registerSingleton(service); return service; diff --git a/test/ui/views/flashcards/flashcards_viewmodel_test.dart b/test/ui/views/flashcards/flashcards_viewmodel_test.dart index 4d9d23e..5ba7ac7 100644 --- a/test/ui/views/flashcards/flashcards_viewmodel_test.dart +++ b/test/ui/views/flashcards/flashcards_viewmodel_test.dart @@ -1981,5 +1981,37 @@ void main() { await dictionaryService.getRecentFlashcardSetReport(flashcardSet); expect(report, null); }); + + test( + 'Add new flashcards in batches when enabled and have no due flashcards', + () async { + // Set shared preferences + getAndRegisterSharedPreferencesService( + getFlashcardLearningModeEnabled: true, + getNewFlashcardsPerDay: 1, + getAddNewFlashcardsInBatches: true, + ); + + // Create dictionary lists to use + final dictionaryList = + await dictionaryService.createMyDictionaryList('list1'); + await dictionaryService.addToMyDictionaryList( + dictionaryList, getVocab1()); + await dictionaryService.addToMyDictionaryList( + dictionaryList, getVocab2()); + + // Create flashcard set and assign lists + final flashcardSet = await dictionaryService.createFlashcardSet('name'); + flashcardSet.myDictionaryLists.add(dictionaryList.id); + await dictionaryService.updateFlashcardSet(flashcardSet); + + // Call initialize + var viewModel = FlashcardsViewModel(flashcardSet, null, randomSeed: 123); + await viewModel.futureToRun(); + + // Check results + expect(viewModel.activeFlashcards.length, 1); + expect(viewModel.newFlashcards.length, 1); + }); }); }