diff --git a/lib/data/default_deck.dart b/lib/data/default_deck.dart index 241394f..14a686a 100644 --- a/lib/data/default_deck.dart +++ b/lib/data/default_deck.dart @@ -142,5 +142,330 @@ class DefaultDeck { config: config, ); } -} + /// Default deck with mock attempt history and progress data + static Deck get deckWithMockData { + const config = DeckConfig( + requiredConsecutiveCorrect: 3, + defaultAttemptSize: 10, + priorityIncreaseOnIncorrect: 5, + priorityDecreaseOnCorrect: 2, + immediateFeedbackEnabled: true, + ); + + final now = DateTime.now(); + + // Create mock attempt history (4 previous attempts) + final attemptHistory = [ + // First attempt - 6/10 correct (60%) + AttemptHistoryEntry( + timestamp: now.subtract(const Duration(days: 7)).millisecondsSinceEpoch, + totalQuestions: 10, + correctAnswers: 6, + incorrectAnswers: 4, + skippedAnswers: 0, + timeSpentSeconds: 245, + ), + // Second attempt - 7/10 correct (70%) + AttemptHistoryEntry( + timestamp: now.subtract(const Duration(days: 5)).millisecondsSinceEpoch, + totalQuestions: 10, + correctAnswers: 7, + incorrectAnswers: 3, + skippedAnswers: 0, + timeSpentSeconds: 198, + ), + // Third attempt - 8/10 correct (80%) + AttemptHistoryEntry( + timestamp: now.subtract(const Duration(days: 3)).millisecondsSinceEpoch, + totalQuestions: 10, + correctAnswers: 8, + incorrectAnswers: 2, + skippedAnswers: 0, + timeSpentSeconds: 187, + ), + // Fourth attempt - 9/10 correct (90%) + AttemptHistoryEntry( + timestamp: now.subtract(const Duration(days: 1)).millisecondsSinceEpoch, + totalQuestions: 10, + correctAnswers: 9, + incorrectAnswers: 1, + skippedAnswers: 0, + timeSpentSeconds: 165, + ), + ]; + + final questions = [ + // Question 1 - Known (answered correctly 3+ times) + Question( + id: 'gk_1', + prompt: 'What is the capital city of Australia?', + answers: ['Sydney', 'Melbourne', 'Canberra', 'Perth'], + correctAnswerIndices: [2], + consecutiveCorrect: 3, + isKnown: true, + priorityPoints: 0, + lastAttemptIndex: 3, + totalCorrectAttempts: 4, + totalAttempts: 4, + ), + // Question 2 - Known + Question( + id: 'gk_2', + prompt: 'Which planet is known as the Red Planet?', + answers: ['Venus', 'Mars', 'Jupiter', 'Saturn'], + correctAnswerIndices: [1], + consecutiveCorrect: 3, + isKnown: true, + priorityPoints: 0, + lastAttemptIndex: 3, + totalCorrectAttempts: 4, + totalAttempts: 4, + ), + // Question 3 - Known + Question( + id: 'gk_3', + prompt: 'What is the largest ocean on Earth?', + answers: ['Atlantic Ocean', 'Indian Ocean', 'Arctic Ocean', 'Pacific Ocean'], + correctAnswerIndices: [3], + consecutiveCorrect: 3, + isKnown: true, + priorityPoints: 0, + lastAttemptIndex: 3, + totalCorrectAttempts: 4, + totalAttempts: 4, + ), + // Question 4 - Known + Question( + id: 'gk_4', + prompt: 'Who wrote the novel "1984"?', + answers: ['George Orwell', 'Aldous Huxley', 'Ray Bradbury', 'J.D. Salinger'], + correctAnswerIndices: [0], + consecutiveCorrect: 3, + isKnown: true, + priorityPoints: 0, + lastAttemptIndex: 3, + totalCorrectAttempts: 4, + totalAttempts: 4, + ), + // Question 5 - Known + Question( + id: 'gk_5', + prompt: 'What is the chemical symbol for gold?', + answers: ['Go', 'Gd', 'Au', 'Ag'], + correctAnswerIndices: [2], + consecutiveCorrect: 3, + isKnown: true, + priorityPoints: 0, + lastAttemptIndex: 3, + totalCorrectAttempts: 4, + totalAttempts: 4, + ), + // Question 6 - Progress (2 consecutive correct, not yet known) + Question( + id: 'gk_6', + prompt: 'In which year did World War II end?', + answers: ['1943', '1944', '1945', '1946'], + correctAnswerIndices: [2], + consecutiveCorrect: 2, + isKnown: false, + priorityPoints: 0, + lastAttemptIndex: 3, + totalCorrectAttempts: 3, + totalAttempts: 4, + ), + // Question 7 - Progress (1 consecutive correct) + Question( + id: 'gk_7', + prompt: 'What is the smallest prime number?', + answers: ['0', '1', '2', '3'], + correctAnswerIndices: [2], + consecutiveCorrect: 1, + isKnown: false, + priorityPoints: 0, + lastAttemptIndex: 3, + totalCorrectAttempts: 2, + totalAttempts: 4, + ), + // Question 8 - Some mistakes (has priority points from incorrect answers) + Question( + id: 'gk_8', + prompt: 'Which gas makes up approximately 78% of Earth\'s atmosphere?', + answers: ['Oxygen', 'Carbon Dioxide', 'Nitrogen', 'Argon'], + correctAnswerIndices: [2], + consecutiveCorrect: 0, + isKnown: false, + priorityPoints: 5, + lastAttemptIndex: 2, + totalCorrectAttempts: 2, + totalAttempts: 4, + ), + // Question 9 - Known + Question( + id: 'gk_9', + prompt: 'What is the longest river in the world?', + answers: ['Amazon River', 'Nile River', 'Yangtze River', 'Mississippi River'], + correctAnswerIndices: [1], + consecutiveCorrect: 3, + isKnown: true, + priorityPoints: 0, + lastAttemptIndex: 3, + totalCorrectAttempts: 4, + totalAttempts: 4, + ), + // Question 10 - Known + Question( + id: 'gk_10', + prompt: 'Who painted the Mona Lisa?', + answers: ['Vincent van Gogh', 'Pablo Picasso', 'Leonardo da Vinci', 'Michelangelo'], + correctAnswerIndices: [2], + consecutiveCorrect: 3, + isKnown: true, + priorityPoints: 0, + lastAttemptIndex: 3, + totalCorrectAttempts: 4, + totalAttempts: 4, + ), + // Question 11 - Progress + Question( + id: 'gk_11', + prompt: 'What is the speed of light in a vacuum (approximately)?', + answers: ['300,000 km/s', '150,000 km/s', '450,000 km/s', '600,000 km/s'], + correctAnswerIndices: [0], + consecutiveCorrect: 2, + isKnown: false, + priorityPoints: 0, + lastAttemptIndex: 3, + totalCorrectAttempts: 3, + totalAttempts: 4, + ), + // Question 12 - Known + Question( + id: 'gk_12', + prompt: 'Which country is home to the kangaroo?', + answers: ['New Zealand', 'Australia', 'South Africa', 'Brazil'], + correctAnswerIndices: [1], + consecutiveCorrect: 3, + isKnown: true, + priorityPoints: 0, + lastAttemptIndex: 3, + totalCorrectAttempts: 4, + totalAttempts: 4, + ), + // Question 13 - Some mistakes + Question( + id: 'gk_13', + prompt: 'What is the hardest natural substance on Earth?', + answers: ['Gold', 'Diamond', 'Platinum', 'Titanium'], + correctAnswerIndices: [1], + consecutiveCorrect: 0, + isKnown: false, + priorityPoints: 3, + lastAttemptIndex: 1, + totalCorrectAttempts: 1, + totalAttempts: 4, + ), + // Question 14 - Progress + Question( + id: 'gk_14', + prompt: 'How many continents are there on Earth?', + answers: ['5', '6', '7', '8'], + correctAnswerIndices: [2], + consecutiveCorrect: 1, + isKnown: false, + priorityPoints: 0, + lastAttemptIndex: 3, + totalCorrectAttempts: 2, + totalAttempts: 4, + ), + // Question 15 - Known + Question( + id: 'gk_15', + prompt: 'What is the largest mammal in the world?', + answers: ['African Elephant', 'Blue Whale', 'Giraffe', 'Polar Bear'], + correctAnswerIndices: [1], + consecutiveCorrect: 3, + isKnown: true, + priorityPoints: 0, + lastAttemptIndex: 3, + totalCorrectAttempts: 4, + totalAttempts: 4, + ), + // Question 16 - Known + Question( + id: 'gk_16', + prompt: 'In which city is the Eiffel Tower located?', + answers: ['London', 'Berlin', 'Paris', 'Rome'], + correctAnswerIndices: [2], + consecutiveCorrect: 3, + isKnown: true, + priorityPoints: 0, + lastAttemptIndex: 3, + totalCorrectAttempts: 4, + totalAttempts: 4, + ), + // Question 17 - Known + Question( + id: 'gk_17', + prompt: 'What is the square root of 64?', + answers: ['6', '7', '8', '9'], + correctAnswerIndices: [2], + consecutiveCorrect: 3, + isKnown: true, + priorityPoints: 0, + lastAttemptIndex: 3, + totalCorrectAttempts: 4, + totalAttempts: 4, + ), + // Question 18 - Progress + Question( + id: 'gk_18', + prompt: 'Which element has the atomic number 1?', + answers: ['Helium', 'Hydrogen', 'Lithium', 'Carbon'], + correctAnswerIndices: [1], + consecutiveCorrect: 2, + isKnown: false, + priorityPoints: 0, + lastAttemptIndex: 3, + totalCorrectAttempts: 3, + totalAttempts: 4, + ), + // Question 19 - Some mistakes + Question( + id: 'gk_19', + prompt: 'What is the largest desert in the world?', + answers: ['Gobi Desert', 'Sahara Desert', 'Antarctic Desert', 'Arabian Desert'], + correctAnswerIndices: [2], + consecutiveCorrect: 0, + isKnown: false, + priorityPoints: 5, + lastAttemptIndex: 0, + totalCorrectAttempts: 1, + totalAttempts: 4, + ), + // Question 20 - Progress + Question( + id: 'gk_20', + prompt: 'Who invented the telephone?', + answers: ['Thomas Edison', 'Alexander Graham Bell', 'Nikola Tesla', 'Guglielmo Marconi'], + correctAnswerIndices: [1], + consecutiveCorrect: 1, + isKnown: false, + priorityPoints: 0, + lastAttemptIndex: 3, + totalCorrectAttempts: 2, + totalAttempts: 4, + ), + ]; + + return Deck( + id: 'default-general-knowledge', + title: 'General Knowledge Quiz', + description: 'A collection of 20 general knowledge questions covering science, geography, history, and more. Perfect for testing your knowledge!', + questions: questions, + config: config, + currentAttemptIndex: 4, // 4 attempts have been made + attemptHistory: attemptHistory, + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 3df8f3c..3a926ef 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'routes.dart'; import 'theme.dart'; +import 'services/deck_storage.dart'; void main() { + // Initialize storage early + DeckStorage().initialize(); runApp(const DeckyApp()); } diff --git a/lib/screens/attempt_result_screen.dart b/lib/screens/attempt_result_screen.dart index 17bddde..a361067 100644 --- a/lib/screens/attempt_result_screen.dart +++ b/lib/screens/attempt_result_screen.dart @@ -73,7 +73,7 @@ class _AttemptResultScreenState extends State { void _done() { if (_deck == null) return; // Save the updated deck to storage - _deckStorage.saveDeck(_deck!); + _deckStorage.saveDeckSync(_deck!); // Navigate back to deck list Navigator.pushNamedAndRemoveUntil( context, diff --git a/lib/screens/attempt_screen.dart b/lib/screens/attempt_screen.dart index 7f6050c..35acaa3 100644 --- a/lib/screens/attempt_screen.dart +++ b/lib/screens/attempt_screen.dart @@ -449,7 +449,7 @@ class _AttemptScreenState extends State { // Update deck with incomplete attempt final updatedDeck = _deck!.copyWith(incompleteAttempt: incompleteAttempt); - _deckStorage.saveDeck(updatedDeck); + _deckStorage.saveDeckSync(updatedDeck); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -485,7 +485,7 @@ class _AttemptScreenState extends State { ); // Save the updated deck to storage - _deckStorage.saveDeck(updatedDeckWithHistory); + _deckStorage.saveDeckSync(updatedDeckWithHistory); Navigator.pushReplacementNamed( context, diff --git a/lib/screens/deck_config_screen.dart b/lib/screens/deck_config_screen.dart index 9b33b61..ad1beaa 100644 --- a/lib/screens/deck_config_screen.dart +++ b/lib/screens/deck_config_screen.dart @@ -225,7 +225,7 @@ class _DeckConfigScreenState extends State { if (updatedDeck != null && updatedDeck is Deck && mounted) { // Reload the deck from storage to get the latest state final deckStorage = DeckStorage(); - final refreshedDeck = deckStorage.getDeck(updatedDeck.id); + final refreshedDeck = deckStorage.getDeckSync(updatedDeck.id); if (refreshedDeck != null) { setState(() { _deck = refreshedDeck; @@ -272,7 +272,7 @@ class _DeckConfigScreenState extends State { // Save the reset deck to storage final deckStorage = DeckStorage(); - deckStorage.saveDeck(resetDeck); + deckStorage.saveDeckSync(resetDeck); // Show success message if (mounted) { diff --git a/lib/screens/deck_create_screen.dart b/lib/screens/deck_create_screen.dart index c94b4c4..4605472 100644 --- a/lib/screens/deck_create_screen.dart +++ b/lib/screens/deck_create_screen.dart @@ -96,7 +96,7 @@ class _DeckCreateScreenState extends State { attemptHistory: [], ); - _deckStorage.saveDeck(newDeck); + _deckStorage.saveDeckSync(newDeck); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( diff --git a/lib/screens/deck_edit_screen.dart b/lib/screens/deck_edit_screen.dart index da1809f..245bc0b 100644 --- a/lib/screens/deck_edit_screen.dart +++ b/lib/screens/deck_edit_screen.dart @@ -118,7 +118,7 @@ class _DeckEditScreenState extends State { currentAttemptIndex: _deck!.currentAttemptIndex, ); - _deckStorage.saveDeck(updatedDeck); + _deckStorage.saveDeckSync(updatedDeck); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( diff --git a/lib/screens/deck_import_screen.dart b/lib/screens/deck_import_screen.dart index f0e3cfb..9324bd4 100644 --- a/lib/screens/deck_import_screen.dart +++ b/lib/screens/deck_import_screen.dart @@ -109,7 +109,7 @@ class _DeckImportScreenState extends State { // Save deck to storage final deckStorage = DeckStorage(); - deckStorage.saveDeck(deck); + deckStorage.saveDeckSync(deck); // Navigate back to deck list Navigator.pop(context); @@ -121,12 +121,11 @@ class _DeckImportScreenState extends State { } } - void _useDefaultDeck() { - final defaultDeck = DefaultDeck.deck; + void _useDefaultDeck() async { final deckStorage = DeckStorage(); // Check if default deck already exists - if (deckStorage.hasDeck(defaultDeck.id)) { + if (deckStorage.hasDeckSync(DefaultDeck.deck.id)) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Default deck already exists'), @@ -136,8 +135,24 @@ class _DeckImportScreenState extends State { return; } + // Show dialog to ask if user wants mock data + final includeMockData = await showDialog( + context: context, + builder: (context) => _MockDataDialog(), + ); + + if (includeMockData == null) { + // User cancelled + return; + } + + // Get the appropriate deck version + final defaultDeck = includeMockData + ? DefaultDeck.deckWithMockData + : DefaultDeck.deck; + // Save default deck to storage - deckStorage.saveDeck(defaultDeck); + deckStorage.saveDeckSync(defaultDeck); // Navigate back to deck list Navigator.pop(context); @@ -420,3 +435,55 @@ class _DeckImportScreenState extends State { } } +class _MockDataDialog extends StatefulWidget { + @override + State<_MockDataDialog> createState() => _MockDataDialogState(); +} + +class _MockDataDialogState extends State<_MockDataDialog> { + bool _includeMockData = false; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Add Default Deck'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Would you like to include mock attempt history and progress data?', + style: TextStyle(fontSize: 14), + ), + const SizedBox(height: 16), + CheckboxListTile( + value: _includeMockData, + onChanged: (value) { + setState(() { + _includeMockData = value ?? false; + }); + }, + title: const Text('Include mock data'), + subtitle: const Text( + 'Adds 4 sample attempts showing progress over time', + style: TextStyle(fontSize: 12), + ), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, null), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, _includeMockData), + child: const Text('Add Deck'), + ), + ], + ); + } +} + diff --git a/lib/screens/deck_list_screen.dart b/lib/screens/deck_list_screen.dart index 2d8908a..b5d6812 100644 --- a/lib/screens/deck_list_screen.dart +++ b/lib/screens/deck_list_screen.dart @@ -22,8 +22,17 @@ class _DeckListScreenState extends State { } void _loadDecks() { + // Use sync version for immediate UI update, will work once storage is initialized setState(() { - _decks = _deckStorage.getAllDecks(); + _decks = _deckStorage.getAllDecksSync(); + }); + // Also trigger async load to ensure we have latest data + _deckStorage.getAllDecks().then((decks) { + if (mounted) { + setState(() { + _decks = decks; + }); + } }); } @@ -61,7 +70,7 @@ class _DeckListScreenState extends State { ); if (confirmed == true) { - _deckStorage.deleteDeck(deck.id); + _deckStorage.deleteDeckSync(deck.id); _loadDecks(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -112,7 +121,7 @@ class _DeckListScreenState extends State { }).toList(), ); - _deckStorage.saveDeck(clonedDeck); + _deckStorage.saveDeckSync(clonedDeck); _loadDecks(); if (mounted) { @@ -181,16 +190,36 @@ class _DeckListScreenState extends State { ); } - void _useDefaultDeck() { - final defaultDeck = DefaultDeck.deck; - + void _useDefaultDeck() async { // Check if default deck already exists - if (_deckStorage.hasDeck(defaultDeck.id)) { + final baseDeck = DefaultDeck.deck; + final deckExists = _deckStorage.hasDeckSync(baseDeck.id); + + // Show dialog to ask if user wants mock data (only if deck doesn't exist) + bool? includeMockData = false; + if (!deckExists) { + includeMockData = await showDialog( + context: context, + builder: (context) => _MockDataDialog(), + ); + + if (includeMockData == null) { + // User cancelled + return; + } + } + + // Get the appropriate deck version + final defaultDeck = includeMockData + ? DefaultDeck.deckWithMockData + : DefaultDeck.deck; + + if (deckExists) { // If it exists, create a copy with a new ID final clonedDeck = defaultDeck.copyWith( id: '${defaultDeck.id}_${DateTime.now().millisecondsSinceEpoch}', ); - _deckStorage.saveDeck(clonedDeck); + _deckStorage.saveDeckSync(clonedDeck); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -202,7 +231,7 @@ class _DeckListScreenState extends State { } } else { // Save default deck to storage - _deckStorage.saveDeck(defaultDeck); + _deckStorage.saveDeckSync(defaultDeck); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -421,3 +450,55 @@ class _StatChip extends StatelessWidget { } } +class _MockDataDialog extends StatefulWidget { + @override + State<_MockDataDialog> createState() => _MockDataDialogState(); +} + +class _MockDataDialogState extends State<_MockDataDialog> { + bool _includeMockData = false; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Add Default Deck'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Would you like to include mock attempt history and progress data?', + style: TextStyle(fontSize: 14), + ), + const SizedBox(height: 16), + CheckboxListTile( + value: _includeMockData, + onChanged: (value) { + setState(() { + _includeMockData = value ?? false; + }); + }, + title: const Text('Include mock data'), + subtitle: const Text( + 'Adds 4 sample attempts showing progress over time', + style: TextStyle(fontSize: 12), + ), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, null), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, _includeMockData), + child: const Text('Add Deck'), + ), + ], + ); + } +} + diff --git a/lib/screens/deck_overview_screen.dart b/lib/screens/deck_overview_screen.dart index 73fff40..2d485e1 100644 --- a/lib/screens/deck_overview_screen.dart +++ b/lib/screens/deck_overview_screen.dart @@ -24,7 +24,7 @@ class _DeckOverviewScreenState extends State { // ALWAYS load the latest version from storage to ensure we have the most up-to-date deck // This is critical for getting the latest incomplete attempt state // Don't rely on route arguments - they might be stale - final storedDeck = _deckStorage.getDeck(args.id); + final storedDeck = _deckStorage.getDeckSync(args.id); final deckToUse = storedDeck ?? args; // Always update to get the latest state from storage @@ -37,7 +37,7 @@ class _DeckOverviewScreenState extends State { void _saveDeck() { if (_deck != null) { - _deckStorage.saveDeck(_deck!); + _deckStorage.saveDeckSync(_deck!); } } @@ -50,7 +50,7 @@ class _DeckOverviewScreenState extends State { // Force reload from storage before checking for incomplete attempts to ensure we have latest state Deck? deckToCheck = _deck; if (_deck != null) { - final freshDeck = _deckStorage.getDeck(_deck!.id); + final freshDeck = _deckStorage.getDeckSync(_deck!.id); if (freshDeck != null) { setState(() { _deck = freshDeck; @@ -117,8 +117,8 @@ class _DeckOverviewScreenState extends State { final updatedDeck = deckToCheck.copyWith(clearIncompleteAttempt: true); // Save to storage multiple times to ensure it's persisted - _deckStorage.saveDeck(updatedDeck); - _deckStorage.saveDeck(updatedDeck); // Save twice to be sure + _deckStorage.saveDeckSync(updatedDeck); + _deckStorage.saveDeckSync(updatedDeck); // Save twice to be sure // Update local state immediately with cleared deck setState(() { @@ -128,13 +128,13 @@ class _DeckOverviewScreenState extends State { // Force reload from storage multiple times to verify it's cleared Deck? finalClearedDeck = updatedDeck; for (int i = 0; i < 3; i++) { - final verifiedDeck = _deckStorage.getDeck(updatedDeck.id); + final verifiedDeck = _deckStorage.getDeckSync(updatedDeck.id); if (verifiedDeck != null) { // Ensure incomplete attempt is null even if storage had it if (verifiedDeck.incompleteAttempt != null) { final clearedDeck = verifiedDeck.copyWith(clearIncompleteAttempt: true); - _deckStorage.saveDeck(clearedDeck); + _deckStorage.saveDeckSync(clearedDeck); setState(() { _deck = clearedDeck; }); @@ -211,7 +211,7 @@ class _DeckOverviewScreenState extends State { // Also ensure storage has the cleared state final deckToUse = deckToCheck.copyWith(clearIncompleteAttempt: true); // Ensure storage also has the cleared state - _deckStorage.saveDeck(deckToUse); + _deckStorage.saveDeckSync(deckToUse); Navigator.pushNamed( context, Routes.attempt, @@ -226,7 +226,7 @@ class _DeckOverviewScreenState extends State { void _refreshDeck() { if (_deck != null) { // Reload the deck from storage to get the latest state (including incomplete attempts) - final refreshedDeck = _deckStorage.getDeck(_deck!.id); + final refreshedDeck = _deckStorage.getDeckSync(_deck!.id); if (refreshedDeck != null && mounted) { setState(() { _deck = refreshedDeck; diff --git a/lib/services/deck_storage.dart b/lib/services/deck_storage.dart index 1a208c2..f6168e1 100644 --- a/lib/services/deck_storage.dart +++ b/lib/services/deck_storage.dart @@ -1,61 +1,353 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:practice_engine/practice_engine.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../data/default_deck.dart'; /// Service for managing deck storage and retrieval. -/// Currently uses in-memory storage. Can be extended to use persistent storage. +/// Uses persistent storage via shared_preferences. class DeckStorage { static final DeckStorage _instance = DeckStorage._internal(); factory DeckStorage() => _instance; - DeckStorage._internal(); - - final Map _decks = {}; + + static const String _decksKey = 'decks'; + static const String _deckIdsKey = 'deck_ids'; bool _initialized = false; + SharedPreferences? _prefs; + final Future _initFuture; + + DeckStorage._internal() : _initFuture = SharedPreferences.getInstance().then((prefs) { + _instance._prefs = prefs; + _instance._initialized = true; + }); /// Initialize storage with default deck if empty - void initialize() { + Future initialize() async { if (_initialized) return; - _initialized = true; - + await _initFuture; + + // Load existing decks + final deckIds = _prefs!.getStringList(_deckIdsKey) ?? []; + // Add default deck if no decks exist - if (_decks.isEmpty) { + if (deckIds.isEmpty) { final defaultDeck = DefaultDeck.deck; - _decks[defaultDeck.id] = defaultDeck; + await saveDeck(defaultDeck); + } + } + + /// Ensure storage is initialized + Future _ensureInitialized() async { + if (!_initialized) { + await _initFuture; + await initialize(); } } + /// Convert Deck to JSON Map + Map _deckToJson(Deck deck) { + return { + 'id': deck.id, + 'title': deck.title, + 'description': deck.description, + 'currentAttemptIndex': deck.currentAttemptIndex, + 'config': { + 'requiredConsecutiveCorrect': deck.config.requiredConsecutiveCorrect, + 'defaultAttemptSize': deck.config.defaultAttemptSize, + 'priorityIncreaseOnIncorrect': deck.config.priorityIncreaseOnIncorrect, + 'priorityDecreaseOnCorrect': deck.config.priorityDecreaseOnCorrect, + 'immediateFeedbackEnabled': deck.config.immediateFeedbackEnabled, + 'includeKnownInAttempts': deck.config.includeKnownInAttempts, + 'timeLimitSeconds': deck.config.timeLimitSeconds, + }, + 'questions': deck.questions.map((q) => { + 'id': q.id, + 'prompt': q.prompt, + 'answers': q.answers, + 'correctAnswerIndices': q.correctAnswerIndices, + 'consecutiveCorrect': q.consecutiveCorrect, + 'isKnown': q.isKnown, + 'priorityPoints': q.priorityPoints, + 'lastAttemptIndex': q.lastAttemptIndex, + 'totalCorrectAttempts': q.totalCorrectAttempts, + 'totalAttempts': q.totalAttempts, + }).toList(), + 'attemptHistory': deck.attemptHistory.map((entry) => { + 'timestamp': entry.timestamp, + 'totalQuestions': entry.totalQuestions, + 'correctAnswers': entry.correctAnswers, + 'incorrectAnswers': entry.incorrectAnswers, + 'skippedAnswers': entry.skippedAnswers, + 'timeSpentSeconds': entry.timeSpentSeconds, + }).toList(), + 'incompleteAttempt': deck.incompleteAttempt != null ? { + 'attemptId': deck.incompleteAttempt!.attemptId, + 'questionIds': deck.incompleteAttempt!.questionIds, + 'startTime': deck.incompleteAttempt!.startTime, + 'currentQuestionIndex': deck.incompleteAttempt!.currentQuestionIndex, + 'answers': deck.incompleteAttempt!.answers, + 'manualOverrides': deck.incompleteAttempt!.manualOverrides, + 'pausedAt': deck.incompleteAttempt!.pausedAt, + 'remainingSeconds': deck.incompleteAttempt!.remainingSeconds, + } : null, + }; + } + + /// Convert JSON Map to Deck + Deck _jsonToDeck(Map json) { + // Parse config + final configJson = json['config'] as Map? ?? {}; + final config = DeckConfig( + requiredConsecutiveCorrect: configJson['requiredConsecutiveCorrect'] as int? ?? 3, + defaultAttemptSize: configJson['defaultAttemptSize'] as int? ?? 10, + priorityIncreaseOnIncorrect: configJson['priorityIncreaseOnIncorrect'] as int? ?? 5, + priorityDecreaseOnCorrect: configJson['priorityDecreaseOnCorrect'] as int? ?? 2, + immediateFeedbackEnabled: configJson['immediateFeedbackEnabled'] as bool? ?? true, + includeKnownInAttempts: configJson['includeKnownInAttempts'] as bool? ?? false, + timeLimitSeconds: configJson['timeLimitSeconds'] as int?, + ); + + // Parse questions + final questionsJson = json['questions'] as List? ?? []; + final questions = questionsJson.map((qJson) { + final questionMap = qJson as Map; + + List? correctIndices; + if (questionMap['correctAnswerIndices'] != null) { + final indicesJson = questionMap['correctAnswerIndices'] as List?; + correctIndices = indicesJson?.map((e) => e as int).toList(); + } else if (questionMap['correctAnswerIndex'] != null) { + // Backward compatibility + final singleIndex = questionMap['correctAnswerIndex'] as int?; + if (singleIndex != null) { + correctIndices = [singleIndex]; + } + } + + return Question( + id: questionMap['id'] as String? ?? '', + prompt: questionMap['prompt'] as String? ?? '', + answers: (questionMap['answers'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + [], + correctAnswerIndices: correctIndices ?? [0], + consecutiveCorrect: questionMap['consecutiveCorrect'] as int? ?? 0, + isKnown: questionMap['isKnown'] as bool? ?? false, + priorityPoints: questionMap['priorityPoints'] as int? ?? 0, + lastAttemptIndex: questionMap['lastAttemptIndex'] as int? ?? -1, + totalCorrectAttempts: questionMap['totalCorrectAttempts'] as int? ?? 0, + totalAttempts: questionMap['totalAttempts'] as int? ?? 0, + ); + }).toList(); + + // Parse attempt history + final historyJson = json['attemptHistory'] as List? ?? []; + final attemptHistory = historyJson.map((entryJson) { + final entryMap = entryJson as Map; + return AttemptHistoryEntry( + timestamp: entryMap['timestamp'] as int? ?? 0, + totalQuestions: entryMap['totalQuestions'] as int? ?? 0, + correctAnswers: entryMap['correctAnswers'] as int? ?? 0, + incorrectAnswers: entryMap['incorrectAnswers'] as int? ?? 0, + skippedAnswers: entryMap['skippedAnswers'] as int? ?? 0, + timeSpentSeconds: entryMap['timeSpentSeconds'] as int?, + ); + }).toList(); + + // Parse incomplete attempt + IncompleteAttempt? incompleteAttempt; + if (json['incompleteAttempt'] != null) { + final incompleteJson = json['incompleteAttempt'] as Map; + incompleteAttempt = IncompleteAttempt( + attemptId: incompleteJson['attemptId'] as String? ?? '', + questionIds: (incompleteJson['questionIds'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + [], + startTime: incompleteJson['startTime'] as int? ?? 0, + currentQuestionIndex: incompleteJson['currentQuestionIndex'] as int? ?? 0, + answers: Map.from( + incompleteJson['answers'] as Map? ?? {}, + ), + manualOverrides: Map.from( + (incompleteJson['manualOverrides'] as Map?)?.map( + (key, value) => MapEntry(key, value as bool), + ) ?? {}, + ), + pausedAt: incompleteJson['pausedAt'] as int? ?? 0, + remainingSeconds: incompleteJson['remainingSeconds'] as int?, + ); + } + + // Create deck + return Deck( + id: json['id'] as String? ?? DateTime.now().millisecondsSinceEpoch.toString(), + title: json['title'] as String? ?? 'Imported Deck', + description: json['description'] as String? ?? '', + questions: questions, + config: config, + currentAttemptIndex: json['currentAttemptIndex'] as int? ?? 0, + attemptHistory: attemptHistory, + incompleteAttempt: incompleteAttempt, + ); + } + /// Get all decks - List getAllDecks() { - initialize(); - return _decks.values.toList(); + Future> getAllDecks() async { + await _ensureInitialized(); + final deckIds = _prefs!.getStringList(_deckIdsKey) ?? []; + final decks = []; + + for (final id in deckIds) { + final deck = await getDeck(id); + if (deck != null) { + decks.add(deck); + } + } + + return decks; + } + + /// Synchronous version for backward compatibility + List getAllDecksSync() { + if (!_initialized || _prefs == null) { + // Trigger async initialization but return empty list for now + _ensureInitialized(); + return []; + } + + final deckIds = _prefs!.getStringList(_deckIdsKey) ?? []; + final decks = []; + + for (final id in deckIds) { + final deck = getDeckSync(id); + if (deck != null) { + decks.add(deck); + } + } + + return decks; } /// Get a deck by ID - Deck? getDeck(String id) { - initialize(); - return _decks[id]; + Future getDeck(String id) async { + await _ensureInitialized(); + final deckJson = _prefs!.getString('$_decksKey:$id'); + if (deckJson == null) return null; + + try { + final json = jsonDecode(deckJson) as Map; + return _jsonToDeck(json); + } catch (e) { + debugPrint('Error loading deck $id: $e'); + return null; + } + } + + /// Synchronous version for backward compatibility + Deck? getDeckSync(String id) { + if (!_initialized || _prefs == null) { + // Trigger async initialization but return null for now + _ensureInitialized(); + return null; + } + + final deckJson = _prefs!.getString('$_decksKey:$id'); + if (deckJson == null) return null; + + try { + final json = jsonDecode(deckJson) as Map; + return _jsonToDeck(json); + } catch (e) { + debugPrint('Error loading deck $id: $e'); + return null; + } } /// Add or update a deck - void saveDeck(Deck deck) { - initialize(); - _decks[deck.id] = deck; + Future saveDeck(Deck deck) async { + await _ensureInitialized(); + final deckJson = jsonEncode(_deckToJson(deck)); + await _prefs!.setString('$_decksKey:${deck.id}', deckJson); + + // Update deck IDs list + final deckIds = _prefs!.getStringList(_deckIdsKey) ?? []; + if (!deckIds.contains(deck.id)) { + deckIds.add(deck.id); + await _prefs!.setStringList(_deckIdsKey, deckIds); + } + } + + /// Synchronous version for backward compatibility + void saveDeckSync(Deck deck) { + if (!_initialized || _prefs == null) { + // Queue for async save + _ensureInitialized().then((_) => saveDeck(deck)); + return; + } + + final deckJson = jsonEncode(_deckToJson(deck)); + _prefs!.setString('$_decksKey:${deck.id}', deckJson); + + // Update deck IDs list + final deckIds = _prefs!.getStringList(_deckIdsKey) ?? []; + if (!deckIds.contains(deck.id)) { + deckIds.add(deck.id); + _prefs!.setStringList(_deckIdsKey, deckIds); + } } /// Delete a deck - void deleteDeck(String id) { - _decks.remove(id); + Future deleteDeck(String id) async { + await _ensureInitialized(); + await _prefs!.remove('$_decksKey:$id'); + + // Update deck IDs list + final deckIds = _prefs!.getStringList(_deckIdsKey) ?? []; + deckIds.remove(id); + await _prefs!.setStringList(_deckIdsKey, deckIds); + } + + /// Synchronous version for backward compatibility + void deleteDeckSync(String id) { + if (!_initialized || _prefs == null) { + // Queue for async delete + _ensureInitialized().then((_) => deleteDeck(id)); + return; + } + + _prefs!.remove('$_decksKey:$id'); + + // Update deck IDs list + final deckIds = _prefs!.getStringList(_deckIdsKey) ?? []; + deckIds.remove(id); + _prefs!.setStringList(_deckIdsKey, deckIds); } /// Check if a deck exists - bool hasDeck(String id) { - initialize(); - return _decks.containsKey(id); + Future hasDeck(String id) async { + await _ensureInitialized(); + return _prefs!.containsKey('$_decksKey:$id'); + } + + /// Synchronous version for backward compatibility + bool hasDeckSync(String id) { + if (!_initialized || _prefs == null) return false; + return _prefs!.containsKey('$_decksKey:$id'); } /// Get the number of decks - int get deckCount { - initialize(); - return _decks.length; + Future get deckCount async { + await _ensureInitialized(); + final deckIds = _prefs!.getStringList(_deckIdsKey) ?? []; + return deckIds.length; } -} + /// Synchronous version for backward compatibility + int get deckCountSync { + if (!_initialized || _prefs == null) return 0; + final deckIds = _prefs!.getStringList(_deckIdsKey) ?? []; + return deckIds.length; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 9455d56..c90990b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: practice_engine: path: packages/practice_engine cupertino_icons: ^1.0.6 + shared_preferences: ^2.2.2 dev_dependencies: flutter_test: