You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
decky/lib/services/deck_storage.dart

354 lines
12 KiB

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.
/// Uses persistent storage via shared_preferences.
class DeckStorage {
static final DeckStorage _instance = DeckStorage._internal();
factory DeckStorage() => _instance;
static const String _decksKey = 'decks';
static const String _deckIdsKey = 'deck_ids';
bool _initialized = false;
SharedPreferences? _prefs;
final Future<void> _initFuture;
DeckStorage._internal() : _initFuture = SharedPreferences.getInstance().then((prefs) {
_instance._prefs = prefs;
_instance._initialized = true;
});
/// Initialize storage with default deck if empty
Future<void> initialize() async {
if (_initialized) return;
await _initFuture;
// Load existing decks
final deckIds = _prefs!.getStringList(_deckIdsKey) ?? [];
// Add default deck if no decks exist
if (deckIds.isEmpty) {
final defaultDeck = DefaultDeck.deck;
await saveDeck(defaultDeck);
}
}
/// Ensure storage is initialized
Future<void> _ensureInitialized() async {
if (!_initialized) {
await _initFuture;
await initialize();
}
}
/// Convert Deck to JSON Map
Map<String, dynamic> _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<String, dynamic> json) {
// Parse config
final configJson = json['config'] as Map<String, dynamic>? ?? {};
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<dynamic>? ?? [];
final questions = questionsJson.map((qJson) {
final questionMap = qJson as Map<String, dynamic>;
List<int>? correctIndices;
if (questionMap['correctAnswerIndices'] != null) {
final indicesJson = questionMap['correctAnswerIndices'] as List<dynamic>?;
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<dynamic>?)
?.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<dynamic>? ?? [];
final attemptHistory = historyJson.map((entryJson) {
final entryMap = entryJson as Map<String, dynamic>;
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<String, dynamic>;
incompleteAttempt = IncompleteAttempt(
attemptId: incompleteJson['attemptId'] as String? ?? '',
questionIds: (incompleteJson['questionIds'] as List<dynamic>?)
?.map((e) => e.toString())
.toList() ??
[],
startTime: incompleteJson['startTime'] as int? ?? 0,
currentQuestionIndex: incompleteJson['currentQuestionIndex'] as int? ?? 0,
answers: Map<String, dynamic>.from(
incompleteJson['answers'] as Map<String, dynamic>? ?? {},
),
manualOverrides: Map<String, bool>.from(
(incompleteJson['manualOverrides'] as Map<String, dynamic>?)?.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
Future<List<Deck>> getAllDecks() async {
await _ensureInitialized();
final deckIds = _prefs!.getStringList(_deckIdsKey) ?? [];
final decks = <Deck>[];
for (final id in deckIds) {
final deck = await getDeck(id);
if (deck != null) {
decks.add(deck);
}
}
return decks;
}
/// Synchronous version for backward compatibility
List<Deck> getAllDecksSync() {
if (!_initialized || _prefs == null) {
// Trigger async initialization but return empty list for now
_ensureInitialized();
return [];
}
final deckIds = _prefs!.getStringList(_deckIdsKey) ?? [];
final decks = <Deck>[];
for (final id in deckIds) {
final deck = getDeckSync(id);
if (deck != null) {
decks.add(deck);
}
}
return decks;
}
/// Get a deck by ID
Future<Deck?> getDeck(String id) async {
await _ensureInitialized();
final deckJson = _prefs!.getString('$_decksKey:$id');
if (deckJson == null) return null;
try {
final json = jsonDecode(deckJson) as Map<String, dynamic>;
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<String, dynamic>;
return _jsonToDeck(json);
} catch (e) {
debugPrint('Error loading deck $id: $e');
return null;
}
}
/// Add or update a deck
Future<void> 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
Future<void> 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
Future<bool> 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
Future<int> 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;
}
}

Powered by TurnKey Linux.