commit
dbcc5e9521
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,146 @@
|
|||||||
|
import 'package:practice_engine/practice_engine.dart';
|
||||||
|
|
||||||
|
/// Default deck with 20 general knowledge questions for practice.
|
||||||
|
class DefaultDeck {
|
||||||
|
static Deck get deck {
|
||||||
|
const config = DeckConfig(
|
||||||
|
requiredConsecutiveCorrect: 3,
|
||||||
|
defaultAttemptSize: 10,
|
||||||
|
priorityIncreaseOnIncorrect: 5,
|
||||||
|
priorityDecreaseOnCorrect: 2,
|
||||||
|
immediateFeedbackEnabled: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
final questions = [
|
||||||
|
Question(
|
||||||
|
id: 'gk_1',
|
||||||
|
prompt: 'What is the capital city of Australia?',
|
||||||
|
answers: ['Sydney', 'Melbourne', 'Canberra', 'Perth'],
|
||||||
|
correctAnswerIndices: [2],
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
id: 'gk_2',
|
||||||
|
prompt: 'Which planet is known as the Red Planet?',
|
||||||
|
answers: ['Venus', 'Mars', 'Jupiter', 'Saturn'],
|
||||||
|
correctAnswerIndices: [1],
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
id: 'gk_3',
|
||||||
|
prompt: 'What is the largest ocean on Earth?',
|
||||||
|
answers: ['Atlantic Ocean', 'Indian Ocean', 'Arctic Ocean', 'Pacific Ocean'],
|
||||||
|
correctAnswerIndices: [3],
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
id: 'gk_4',
|
||||||
|
prompt: 'Who wrote the novel "1984"?',
|
||||||
|
answers: ['George Orwell', 'Aldous Huxley', 'Ray Bradbury', 'J.D. Salinger'],
|
||||||
|
correctAnswerIndices: [0],
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
id: 'gk_5',
|
||||||
|
prompt: 'What is the chemical symbol for gold?',
|
||||||
|
answers: ['Go', 'Gd', 'Au', 'Ag'],
|
||||||
|
correctAnswerIndices: [2],
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
id: 'gk_6',
|
||||||
|
prompt: 'In which year did World War II end?',
|
||||||
|
answers: ['1943', '1944', '1945', '1946'],
|
||||||
|
correctAnswerIndices: [2],
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
id: 'gk_7',
|
||||||
|
prompt: 'What is the smallest prime number?',
|
||||||
|
answers: ['0', '1', '2', '3'],
|
||||||
|
correctAnswerIndices: [2],
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
id: 'gk_8',
|
||||||
|
prompt: 'Which gas makes up approximately 78% of Earth\'s atmosphere?',
|
||||||
|
answers: ['Oxygen', 'Carbon Dioxide', 'Nitrogen', 'Argon'],
|
||||||
|
correctAnswerIndices: [2],
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
id: 'gk_9',
|
||||||
|
prompt: 'What is the longest river in the world?',
|
||||||
|
answers: ['Amazon River', 'Nile River', 'Yangtze River', 'Mississippi River'],
|
||||||
|
correctAnswerIndices: [1],
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
id: 'gk_10',
|
||||||
|
prompt: 'Who painted the Mona Lisa?',
|
||||||
|
answers: ['Vincent van Gogh', 'Pablo Picasso', 'Leonardo da Vinci', 'Michelangelo'],
|
||||||
|
correctAnswerIndices: [2],
|
||||||
|
),
|
||||||
|
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],
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
id: 'gk_12',
|
||||||
|
prompt: 'Which country is home to the kangaroo?',
|
||||||
|
answers: ['New Zealand', 'Australia', 'South Africa', 'Brazil'],
|
||||||
|
correctAnswerIndices: [1],
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
id: 'gk_13',
|
||||||
|
prompt: 'What is the hardest natural substance on Earth?',
|
||||||
|
answers: ['Gold', 'Diamond', 'Platinum', 'Titanium'],
|
||||||
|
correctAnswerIndices: [1],
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
id: 'gk_14',
|
||||||
|
prompt: 'How many continents are there on Earth?',
|
||||||
|
answers: ['5', '6', '7', '8'],
|
||||||
|
correctAnswerIndices: [2],
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
id: 'gk_15',
|
||||||
|
prompt: 'What is the largest mammal in the world?',
|
||||||
|
answers: ['African Elephant', 'Blue Whale', 'Giraffe', 'Polar Bear'],
|
||||||
|
correctAnswerIndices: [1],
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
id: 'gk_16',
|
||||||
|
prompt: 'In which city is the Eiffel Tower located?',
|
||||||
|
answers: ['London', 'Berlin', 'Paris', 'Rome'],
|
||||||
|
correctAnswerIndices: [2],
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
id: 'gk_17',
|
||||||
|
prompt: 'What is the square root of 64?',
|
||||||
|
answers: ['6', '7', '8', '9'],
|
||||||
|
correctAnswerIndices: [2],
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
id: 'gk_18',
|
||||||
|
prompt: 'Which element has the atomic number 1?',
|
||||||
|
answers: ['Helium', 'Hydrogen', 'Lithium', 'Carbon'],
|
||||||
|
correctAnswerIndices: [1],
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
id: 'gk_19',
|
||||||
|
prompt: 'What is the largest desert in the world?',
|
||||||
|
answers: ['Gobi Desert', 'Sahara Desert', 'Antarctic Desert', 'Arabian Desert'],
|
||||||
|
correctAnswerIndices: [2],
|
||||||
|
),
|
||||||
|
Question(
|
||||||
|
id: 'gk_20',
|
||||||
|
prompt: 'Who invented the telephone?',
|
||||||
|
answers: ['Thomas Edison', 'Alexander Graham Bell', 'Nikola Tesla', 'Guglielmo Marconi'],
|
||||||
|
correctAnswerIndices: [1],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,215 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:practice_engine/practice_engine.dart';
|
||||||
|
import '../services/deck_storage.dart';
|
||||||
|
import 'deck_edit_screen.dart';
|
||||||
|
|
||||||
|
class DeckCreateScreen extends StatefulWidget {
|
||||||
|
const DeckCreateScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DeckCreateScreen> createState() => _DeckCreateScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DeckCreateScreenState extends State<DeckCreateScreen> {
|
||||||
|
late TextEditingController _titleController;
|
||||||
|
late TextEditingController _descriptionController;
|
||||||
|
final List<QuestionEditor> _questionEditors = [];
|
||||||
|
final DeckStorage _deckStorage = DeckStorage();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_titleController = TextEditingController();
|
||||||
|
_descriptionController = TextEditingController();
|
||||||
|
// Start with one empty question
|
||||||
|
_questionEditors.add(QuestionEditor.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleController.dispose();
|
||||||
|
_descriptionController.dispose();
|
||||||
|
for (final editor in _questionEditors) {
|
||||||
|
editor.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addQuestion() {
|
||||||
|
setState(() {
|
||||||
|
_questionEditors.add(QuestionEditor.empty());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeQuestion(int index) {
|
||||||
|
setState(() {
|
||||||
|
_questionEditors.removeAt(index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _save() {
|
||||||
|
final title = _titleController.text.trim();
|
||||||
|
if (title.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Deck title cannot be empty'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all questions
|
||||||
|
final questions = <Question>[];
|
||||||
|
for (int i = 0; i < _questionEditors.length; i++) {
|
||||||
|
final editor = _questionEditors[i];
|
||||||
|
if (!editor.isValid) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Question ${i + 1} is invalid. Please fill in all fields.'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
questions.add(editor.toQuestion());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (questions.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Deck must have at least one question'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new deck
|
||||||
|
final newDeck = Deck(
|
||||||
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
title: title,
|
||||||
|
description: _descriptionController.text.trim(),
|
||||||
|
questions: questions,
|
||||||
|
config: const DeckConfig(),
|
||||||
|
currentAttemptIndex: 0,
|
||||||
|
attemptHistory: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
_deckStorage.saveDeck(newDeck);
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Deck created successfully'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
duration: Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Navigator.pop(context, newDeck);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Create New Deck'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.save),
|
||||||
|
onPressed: _save,
|
||||||
|
tooltip: 'Save Deck',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Deck Title
|
||||||
|
TextField(
|
||||||
|
controller: _titleController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Deck Title',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Deck Description
|
||||||
|
TextField(
|
||||||
|
controller: _descriptionController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Description (optional)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Questions Section
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Questions',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _addQuestion,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Add Question'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Questions List
|
||||||
|
...List.generate(_questionEditors.length, (index) {
|
||||||
|
return QuestionEditorCard(
|
||||||
|
key: ValueKey('question_$index'),
|
||||||
|
editor: _questionEditors[index],
|
||||||
|
questionNumber: index + 1,
|
||||||
|
onDelete: () => _removeQuestion(index),
|
||||||
|
onChanged: () => setState(() {}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
if (_questionEditors.isEmpty)
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.quiz_outlined,
|
||||||
|
size: 48,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No questions yet',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Tap "Add Question" to get started',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,455 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:practice_engine/practice_engine.dart';
|
||||||
|
import '../services/deck_storage.dart';
|
||||||
|
|
||||||
|
class DeckEditScreen extends StatefulWidget {
|
||||||
|
const DeckEditScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DeckEditScreen> createState() => _DeckEditScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DeckEditScreenState extends State<DeckEditScreen> {
|
||||||
|
Deck? _deck;
|
||||||
|
late TextEditingController _titleController;
|
||||||
|
late TextEditingController _descriptionController;
|
||||||
|
final List<QuestionEditor> _questionEditors = [];
|
||||||
|
final DeckStorage _deckStorage = DeckStorage();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
if (_deck == null) {
|
||||||
|
final args = ModalRoute.of(context)?.settings.arguments;
|
||||||
|
if (args is Deck) {
|
||||||
|
_initializeFromDeck(args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeFromDeck(Deck deck) {
|
||||||
|
setState(() {
|
||||||
|
_deck = deck;
|
||||||
|
_titleController = TextEditingController(text: deck.title);
|
||||||
|
_descriptionController = TextEditingController(text: deck.description);
|
||||||
|
|
||||||
|
_questionEditors.clear();
|
||||||
|
for (final question in deck.questions) {
|
||||||
|
_questionEditors.add(QuestionEditor.fromQuestion(question));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleController.dispose();
|
||||||
|
_descriptionController.dispose();
|
||||||
|
for (final editor in _questionEditors) {
|
||||||
|
editor.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addQuestion() {
|
||||||
|
setState(() {
|
||||||
|
_questionEditors.add(QuestionEditor.empty());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeQuestion(int index) {
|
||||||
|
setState(() {
|
||||||
|
_questionEditors.removeAt(index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _save() {
|
||||||
|
if (_deck == null) return;
|
||||||
|
|
||||||
|
final title = _titleController.text.trim();
|
||||||
|
if (title.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Deck title cannot be empty'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all questions
|
||||||
|
final questions = <Question>[];
|
||||||
|
for (int i = 0; i < _questionEditors.length; i++) {
|
||||||
|
final editor = _questionEditors[i];
|
||||||
|
if (!editor.isValid) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Question ${i + 1} is invalid. Please fill in all fields.'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
questions.add(editor.toQuestion());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (questions.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Deck must have at least one question'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve attempt history and other deck data when editing
|
||||||
|
final updatedDeck = _deck!.copyWith(
|
||||||
|
title: title,
|
||||||
|
description: _descriptionController.text.trim(),
|
||||||
|
questions: questions,
|
||||||
|
// Preserve attempt history, config, and current attempt index
|
||||||
|
attemptHistory: _deck!.attemptHistory,
|
||||||
|
config: _deck!.config,
|
||||||
|
currentAttemptIndex: _deck!.currentAttemptIndex,
|
||||||
|
);
|
||||||
|
|
||||||
|
_deckStorage.saveDeck(updatedDeck);
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Deck saved successfully'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
duration: Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Navigator.pop(context, updatedDeck);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_deck == null) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Edit Deck'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.save),
|
||||||
|
onPressed: _save,
|
||||||
|
tooltip: 'Save Deck',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Deck Title
|
||||||
|
TextField(
|
||||||
|
controller: _titleController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Deck Title',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Deck Description
|
||||||
|
TextField(
|
||||||
|
controller: _descriptionController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Description (optional)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Questions Section
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Questions',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _addQuestion,
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Add Question'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Questions List
|
||||||
|
...List.generate(_questionEditors.length, (index) {
|
||||||
|
return QuestionEditorCard(
|
||||||
|
key: ValueKey('question_$index'),
|
||||||
|
editor: _questionEditors[index],
|
||||||
|
questionNumber: index + 1,
|
||||||
|
onDelete: () => _removeQuestion(index),
|
||||||
|
onChanged: () => setState(() {}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
if (_questionEditors.isEmpty)
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.quiz_outlined,
|
||||||
|
size: 48,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No questions yet',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Tap "Add Question" to get started',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class QuestionEditorCard extends StatefulWidget {
|
||||||
|
final QuestionEditor editor;
|
||||||
|
final int questionNumber;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
final VoidCallback onChanged;
|
||||||
|
|
||||||
|
const QuestionEditorCard({
|
||||||
|
super.key,
|
||||||
|
required this.editor,
|
||||||
|
required this.questionNumber,
|
||||||
|
required this.onDelete,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<QuestionEditorCard> createState() => _QuestionEditorCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QuestionEditorCardState extends State<QuestionEditorCard> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Question ${widget.questionNumber}',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete, color: Colors.red),
|
||||||
|
onPressed: widget.onDelete,
|
||||||
|
tooltip: 'Delete Question',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// Question Prompt
|
||||||
|
TextField(
|
||||||
|
controller: widget.editor.promptController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Question',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Answers
|
||||||
|
Text(
|
||||||
|
'Answers',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
...List.generate(widget.editor.answerControllers.length, (index) {
|
||||||
|
final isCorrect = widget.editor.correctAnswerIndices.contains(index);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: isCorrect,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
if (value == true) {
|
||||||
|
widget.editor.correctAnswerIndices.add(index);
|
||||||
|
} else {
|
||||||
|
widget.editor.correctAnswerIndices.remove(index);
|
||||||
|
// Ensure at least one answer is marked as correct
|
||||||
|
if (widget.editor.correctAnswerIndices.isEmpty &&
|
||||||
|
widget.editor.answerControllers.length > 0) {
|
||||||
|
widget.editor.correctAnswerIndices.add(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
widget.onChanged();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: widget.editor.answerControllers[index],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Answer ${index + 1}',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
suffixIcon: isCorrect
|
||||||
|
? const Icon(Icons.check_circle, color: Colors.green)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Add/Remove Answer buttons
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
widget.editor.answerControllers.add(TextEditingController());
|
||||||
|
});
|
||||||
|
widget.onChanged();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Add Answer'),
|
||||||
|
),
|
||||||
|
if (widget.editor.answerControllers.length > 2)
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
final lastIndex = widget.editor.answerControllers.length - 1;
|
||||||
|
// Remove the index from correct answers if it was marked
|
||||||
|
widget.editor.correctAnswerIndices.remove(lastIndex);
|
||||||
|
// Adjust indices if needed
|
||||||
|
widget.editor.correctAnswerIndices = widget.editor.correctAnswerIndices
|
||||||
|
.where((idx) => idx < lastIndex)
|
||||||
|
.toSet();
|
||||||
|
// Ensure at least one answer is marked as correct
|
||||||
|
if (widget.editor.correctAnswerIndices.isEmpty &&
|
||||||
|
widget.editor.answerControllers.length > 1) {
|
||||||
|
widget.editor.correctAnswerIndices.add(0);
|
||||||
|
}
|
||||||
|
widget.editor.answerControllers.removeLast().dispose();
|
||||||
|
});
|
||||||
|
widget.onChanged();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.remove),
|
||||||
|
label: const Text('Remove Answer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class QuestionEditor {
|
||||||
|
final TextEditingController promptController;
|
||||||
|
final List<TextEditingController> answerControllers;
|
||||||
|
Set<int> correctAnswerIndices;
|
||||||
|
final String? originalId;
|
||||||
|
|
||||||
|
QuestionEditor({
|
||||||
|
required this.promptController,
|
||||||
|
required this.answerControllers,
|
||||||
|
Set<int>? correctAnswerIndices,
|
||||||
|
this.originalId,
|
||||||
|
}) : correctAnswerIndices = correctAnswerIndices ?? {0};
|
||||||
|
|
||||||
|
factory QuestionEditor.fromQuestion(Question question) {
|
||||||
|
return QuestionEditor(
|
||||||
|
promptController: TextEditingController(text: question.prompt),
|
||||||
|
answerControllers: question.answers
|
||||||
|
.map((answer) => TextEditingController(text: answer))
|
||||||
|
.toList(),
|
||||||
|
correctAnswerIndices: question.correctIndices.toSet(),
|
||||||
|
originalId: question.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory QuestionEditor.empty() {
|
||||||
|
return QuestionEditor(
|
||||||
|
promptController: TextEditingController(),
|
||||||
|
answerControllers: [
|
||||||
|
TextEditingController(),
|
||||||
|
TextEditingController(),
|
||||||
|
],
|
||||||
|
correctAnswerIndices: {0},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isValid {
|
||||||
|
if (promptController.text.trim().isEmpty) return false;
|
||||||
|
if (answerControllers.length < 2) return false;
|
||||||
|
for (final controller in answerControllers) {
|
||||||
|
if (controller.text.trim().isEmpty) return false;
|
||||||
|
}
|
||||||
|
if (correctAnswerIndices.isEmpty) return false;
|
||||||
|
if (correctAnswerIndices.any((idx) => idx < 0 || idx >= answerControllers.length)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Question toQuestion() {
|
||||||
|
return Question(
|
||||||
|
id: originalId ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
prompt: promptController.text.trim(),
|
||||||
|
answers: answerControllers.map((c) => c.text.trim()).toList(),
|
||||||
|
correctAnswerIndices: correctAnswerIndices.toList()..sort(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
promptController.dispose();
|
||||||
|
for (final controller in answerControllers) {
|
||||||
|
controller.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,423 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:practice_engine/practice_engine.dart';
|
||||||
|
import '../routes.dart';
|
||||||
|
import '../services/deck_storage.dart';
|
||||||
|
import '../data/default_deck.dart';
|
||||||
|
|
||||||
|
class DeckListScreen extends StatefulWidget {
|
||||||
|
const DeckListScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DeckListScreen> createState() => _DeckListScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DeckListScreenState extends State<DeckListScreen> {
|
||||||
|
final DeckStorage _deckStorage = DeckStorage();
|
||||||
|
List<Deck> _decks = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadDecks();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadDecks() {
|
||||||
|
setState(() {
|
||||||
|
_decks = _deckStorage.getAllDecks();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openDeck(Deck deck) {
|
||||||
|
Navigator.pushNamed(
|
||||||
|
context,
|
||||||
|
Routes.deckOverview,
|
||||||
|
arguments: deck,
|
||||||
|
).then((_) {
|
||||||
|
// Reload decks when returning from overview (in case deck was updated)
|
||||||
|
_loadDecks();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _deleteDeck(Deck deck) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Delete Deck'),
|
||||||
|
content: Text('Are you sure you want to delete "${deck.title}"? This action cannot be undone.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
child: const Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true) {
|
||||||
|
_deckStorage.deleteDeck(deck.id);
|
||||||
|
_loadDecks();
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('${deck.title} deleted'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateToImport() {
|
||||||
|
Navigator.pushNamed(context, Routes.deckImport).then((_) {
|
||||||
|
// Reload decks when returning from import
|
||||||
|
_loadDecks();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _editDeck(Deck deck) {
|
||||||
|
Navigator.pushNamed(
|
||||||
|
context,
|
||||||
|
Routes.deckEdit,
|
||||||
|
arguments: deck,
|
||||||
|
).then((updatedDeck) {
|
||||||
|
if (updatedDeck != null) {
|
||||||
|
_loadDecks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cloneDeck(Deck deck) {
|
||||||
|
// Create a copy of the deck with a new ID and reset progress
|
||||||
|
final clonedDeck = deck.copyWith(
|
||||||
|
id: '${deck.id}_clone_${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
title: '${deck.title} (Copy)',
|
||||||
|
currentAttemptIndex: 0,
|
||||||
|
attemptHistory: [],
|
||||||
|
questions: deck.questions.map((q) {
|
||||||
|
return q.copyWith(
|
||||||
|
consecutiveCorrect: 0,
|
||||||
|
isKnown: false,
|
||||||
|
priorityPoints: 0,
|
||||||
|
lastAttemptIndex: -1,
|
||||||
|
totalCorrectAttempts: 0,
|
||||||
|
totalAttempts: 0,
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
_deckStorage.saveDeck(clonedDeck);
|
||||||
|
_loadDecks();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('${deck.title} cloned successfully'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAddDeckOptions() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Add New Deck',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Import JSON
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.upload_file),
|
||||||
|
title: const Text('Import JSON'),
|
||||||
|
subtitle: const Text('Import a deck from JSON format'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_navigateToImport();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
// Create Manually
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.edit),
|
||||||
|
title: const Text('Create Manually'),
|
||||||
|
subtitle: const Text('Create a new deck from scratch'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
Navigator.pushNamed(context, Routes.deckCreate).then((_) {
|
||||||
|
_loadDecks();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
// Use Default Quiz
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.quiz),
|
||||||
|
title: const Text('Use Default Quiz'),
|
||||||
|
subtitle: const Text('Add the default general knowledge quiz'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_useDefaultDeck();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _useDefaultDeck() {
|
||||||
|
final defaultDeck = DefaultDeck.deck;
|
||||||
|
|
||||||
|
// Check if default deck already exists
|
||||||
|
if (_deckStorage.hasDeck(defaultDeck.id)) {
|
||||||
|
// If it exists, create a copy with a new ID
|
||||||
|
final clonedDeck = defaultDeck.copyWith(
|
||||||
|
id: '${defaultDeck.id}_${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
);
|
||||||
|
_deckStorage.saveDeck(clonedDeck);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Default deck added successfully'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Save default deck to storage
|
||||||
|
_deckStorage.saveDeck(defaultDeck);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Default deck added successfully'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadDecks();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Decky'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
tooltip: 'Add Deck',
|
||||||
|
onPressed: _showAddDeckOptions,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _decks.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.inbox,
|
||||||
|
size: 64,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No decks yet',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Tap the + button to add a deck',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
_loadDecks();
|
||||||
|
},
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
itemCount: _decks.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final deck = _decks[index];
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _openDeck(deck),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
deck.title,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (deck.description.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
deck.description,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuButton(
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.edit, color: Colors.blue),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Edit'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Future.delayed(
|
||||||
|
const Duration(milliseconds: 100),
|
||||||
|
() => _editDeck(deck),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.copy, color: Colors.orange),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Clone'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Future.delayed(
|
||||||
|
const Duration(milliseconds: 100),
|
||||||
|
() => _cloneDeck(deck),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.delete, color: Colors.red),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Delete'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Future.delayed(
|
||||||
|
const Duration(milliseconds: 100),
|
||||||
|
() => _deleteDeck(deck),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _StatChip(
|
||||||
|
icon: Icons.quiz,
|
||||||
|
label: '${deck.numberOfQuestions} questions',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _StatChip(
|
||||||
|
icon: Icons.check_circle,
|
||||||
|
label: '${deck.knownCount} known',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _StatChip(
|
||||||
|
icon: Icons.trending_up,
|
||||||
|
label: '${deck.practicePercentage.toStringAsFixed(0)}%',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatChip extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const _StatChip({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 16),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
import 'package:practice_engine/practice_engine.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.
|
||||||
|
class DeckStorage {
|
||||||
|
static final DeckStorage _instance = DeckStorage._internal();
|
||||||
|
factory DeckStorage() => _instance;
|
||||||
|
DeckStorage._internal();
|
||||||
|
|
||||||
|
final Map<String, Deck> _decks = {};
|
||||||
|
bool _initialized = false;
|
||||||
|
|
||||||
|
/// Initialize storage with default deck if empty
|
||||||
|
void initialize() {
|
||||||
|
if (_initialized) return;
|
||||||
|
_initialized = true;
|
||||||
|
|
||||||
|
// Add default deck if no decks exist
|
||||||
|
if (_decks.isEmpty) {
|
||||||
|
final defaultDeck = DefaultDeck.deck;
|
||||||
|
_decks[defaultDeck.id] = defaultDeck;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all decks
|
||||||
|
List<Deck> getAllDecks() {
|
||||||
|
initialize();
|
||||||
|
return _decks.values.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a deck by ID
|
||||||
|
Deck? getDeck(String id) {
|
||||||
|
initialize();
|
||||||
|
return _decks[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add or update a deck
|
||||||
|
void saveDeck(Deck deck) {
|
||||||
|
initialize();
|
||||||
|
_decks[deck.id] = deck;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a deck
|
||||||
|
void deleteDeck(String id) {
|
||||||
|
_decks.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a deck exists
|
||||||
|
bool hasDeck(String id) {
|
||||||
|
initialize();
|
||||||
|
return _decks.containsKey(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of decks
|
||||||
|
int get deckCount {
|
||||||
|
initialize();
|
||||||
|
return _decks.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,255 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:practice_engine/practice_engine.dart';
|
||||||
|
|
||||||
|
/// A chart widget that visualizes attempt progress over time.
|
||||||
|
class AttemptsChart extends StatelessWidget {
|
||||||
|
final List<AttemptHistoryEntry> attempts;
|
||||||
|
final int maxDisplayItems;
|
||||||
|
|
||||||
|
const AttemptsChart({
|
||||||
|
super.key,
|
||||||
|
required this.attempts,
|
||||||
|
this.maxDisplayItems = 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (attempts.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recent attempts (most recent first, then reverse for display)
|
||||||
|
final displayAttempts = attempts.reversed.take(maxDisplayItems).toList();
|
||||||
|
if (displayAttempts.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find min and max values for scaling
|
||||||
|
final percentages = displayAttempts.map((e) => e.percentageCorrect).toList();
|
||||||
|
final minValue = percentages.reduce((a, b) => a < b ? a : b);
|
||||||
|
final maxValue = percentages.reduce((a, b) => a > b ? a : b);
|
||||||
|
final range = (maxValue - minValue).clamp(10.0, 100.0); // Ensure minimum range
|
||||||
|
final chartMin = (minValue - range * 0.1).clamp(0.0, 100.0);
|
||||||
|
final chartMax = (maxValue + range * 0.1).clamp(0.0, 100.0);
|
||||||
|
final chartRange = chartMax - chartMin;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: 220,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Y-axis labels
|
||||||
|
SizedBox(
|
||||||
|
width: 40,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${chartMax.toInt()}%',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${((chartMin + chartMax) / 2).toInt()}%',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${chartMin.toInt()}%',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Chart area
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _AttemptsChartPainter(
|
||||||
|
attempts: displayAttempts,
|
||||||
|
chartMin: chartMin,
|
||||||
|
chartMax: chartMax,
|
||||||
|
chartRange: chartRange,
|
||||||
|
colorScheme: Theme.of(context).colorScheme,
|
||||||
|
),
|
||||||
|
child: Container(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// X-axis labels (attempt numbers)
|
||||||
|
SizedBox(
|
||||||
|
height: 20,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'1',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (displayAttempts.length > 1)
|
||||||
|
Text(
|
||||||
|
'${displayAttempts.length}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AttemptsChartPainter extends CustomPainter {
|
||||||
|
final List<AttemptHistoryEntry> attempts;
|
||||||
|
final double chartMin;
|
||||||
|
final double chartMax;
|
||||||
|
final double chartRange;
|
||||||
|
final ColorScheme colorScheme;
|
||||||
|
|
||||||
|
_AttemptsChartPainter({
|
||||||
|
required this.attempts,
|
||||||
|
required this.chartMin,
|
||||||
|
required this.chartMax,
|
||||||
|
required this.chartRange,
|
||||||
|
required this.colorScheme,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
if (attempts.isEmpty) return;
|
||||||
|
|
||||||
|
final padding = 8.0;
|
||||||
|
final chartWidth = size.width - padding * 2;
|
||||||
|
final chartHeight = size.height - padding * 2;
|
||||||
|
final pointSpacing = attempts.length > 1
|
||||||
|
? chartWidth / (attempts.length - 1)
|
||||||
|
: chartWidth; // If only one point, center it
|
||||||
|
|
||||||
|
// Draw grid lines
|
||||||
|
_drawGridLines(canvas, size, padding, chartHeight);
|
||||||
|
|
||||||
|
// Draw line and points
|
||||||
|
final path = Path();
|
||||||
|
final points = <Offset>[];
|
||||||
|
|
||||||
|
for (int i = 0; i < attempts.length; i++) {
|
||||||
|
final percentage = attempts[i].percentageCorrect;
|
||||||
|
final normalizedValue = chartRange > 0
|
||||||
|
? ((percentage - chartMin) / chartRange).clamp(0.0, 1.0)
|
||||||
|
: 0.5; // If all values are the same, center vertically
|
||||||
|
final x = padding + (i * pointSpacing);
|
||||||
|
final y = padding + chartHeight - (normalizedValue * chartHeight);
|
||||||
|
final point = Offset(x, y);
|
||||||
|
points.add(point);
|
||||||
|
|
||||||
|
if (i == 0) {
|
||||||
|
path.moveTo(point.dx, point.dy);
|
||||||
|
} else {
|
||||||
|
path.lineTo(point.dx, point.dy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw the line
|
||||||
|
final linePaint = Paint()
|
||||||
|
..color = colorScheme.primary
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 2.5;
|
||||||
|
|
||||||
|
canvas.drawPath(path, linePaint);
|
||||||
|
|
||||||
|
// Draw points
|
||||||
|
final pointPaint = Paint()
|
||||||
|
..color = colorScheme.primary
|
||||||
|
..style = PaintingStyle.fill;
|
||||||
|
|
||||||
|
final selectedPointPaint = Paint()
|
||||||
|
..color = colorScheme.primaryContainer
|
||||||
|
..style = PaintingStyle.fill;
|
||||||
|
|
||||||
|
for (int i = 0; i < points.length; i++) {
|
||||||
|
final point = points[i];
|
||||||
|
|
||||||
|
// Draw point circle
|
||||||
|
final isRecent = i >= points.length - 3; // Highlight last 3 attempts
|
||||||
|
canvas.drawCircle(
|
||||||
|
point,
|
||||||
|
isRecent ? 5 : 4,
|
||||||
|
isRecent ? selectedPointPaint : pointPaint,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draw outer ring for better visibility
|
||||||
|
final ringPaint = Paint()
|
||||||
|
..color = colorScheme.primary.withValues(alpha: 0.3)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1;
|
||||||
|
canvas.drawCircle(point, isRecent ? 6 : 5, ringPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw average line (dashed)
|
||||||
|
final avgPercentage = attempts.map((e) => e.percentageCorrect).reduce((a, b) => a + b) / attempts.length;
|
||||||
|
final avgNormalized = ((avgPercentage - chartMin) / chartRange).clamp(0.0, 1.0);
|
||||||
|
final avgY = padding + chartHeight - (avgNormalized * chartHeight);
|
||||||
|
|
||||||
|
final avgLinePaint = Paint()
|
||||||
|
..color = colorScheme.secondary.withValues(alpha: 0.5)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1;
|
||||||
|
|
||||||
|
// Draw dashed line manually
|
||||||
|
final dashLength = 5.0;
|
||||||
|
final gapLength = 5.0;
|
||||||
|
double currentX = padding;
|
||||||
|
while (currentX < size.width - padding) {
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(currentX, avgY),
|
||||||
|
Offset((currentX + dashLength).clamp(padding, size.width - padding), avgY),
|
||||||
|
avgLinePaint,
|
||||||
|
);
|
||||||
|
currentX += dashLength + gapLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawGridLines(Canvas canvas, Size size, double padding, double chartHeight) {
|
||||||
|
final gridPaint = Paint()
|
||||||
|
..color = colorScheme.onSurface.withValues(alpha: 0.1)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1;
|
||||||
|
|
||||||
|
// Draw horizontal grid lines (0%, 25%, 50%, 75%, 100%)
|
||||||
|
for (int i = 0; i <= 4; i++) {
|
||||||
|
final value = chartMin + (chartRange * i / 4);
|
||||||
|
final normalized = ((value - chartMin) / chartRange).clamp(0.0, 1.0);
|
||||||
|
final y = padding + chartHeight - (normalized * chartHeight);
|
||||||
|
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(padding, y),
|
||||||
|
Offset(size.width - padding, y),
|
||||||
|
gridPaint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_AttemptsChartPainter oldDelegate) {
|
||||||
|
return oldDelegate.attempts != attempts ||
|
||||||
|
oldDelegate.chartMin != chartMin ||
|
||||||
|
oldDelegate.chartMax != chartMax;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Loading…
Reference in new issue