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/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/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..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(); @@ -716,7 +731,6 @@ class FlashcardsViewModel extends FutureViewModel { ); if (response != null && response.confirmed) { - // Show progress indicator dialog _dialogService.showCustomDialog( variant: DialogType.progressIndicator, title: 'Updating flashcards', @@ -724,20 +738,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()); } 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); + }); }); }