This commit is contained in:
gitea
2025-12-12 14:06:56 +01:00
commit 5644bfde61
38 changed files with 2268 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'routes.dart';
import 'theme.dart';
void main() {
runApp(const DeckyApp());
}
class DeckyApp extends StatelessWidget {
const DeckyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Decky - Practice Engine',
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system,
initialRoute: Routes.deckImport,
routes: Routes.routes,
debugShowCheckedModeBanner: false,
);
}
}
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'screens/deck_import_screen.dart';
import 'screens/deck_overview_screen.dart';
import 'screens/deck_config_screen.dart';
import 'screens/attempt_screen.dart';
import 'screens/attempt_result_screen.dart';
class Routes {
static const String deckImport = '/';
static const String deckOverview = '/deck-overview';
static const String deckConfig = '/deck-config';
static const String attempt = '/attempt';
static const String attemptResult = '/attempt-result';
static Map<String, WidgetBuilder> get routes {
return {
deckImport: (context) => const DeckImportScreen(),
deckOverview: (context) => const DeckOverviewScreen(),
deckConfig: (context) => const DeckConfigScreen(),
attempt: (context) => const AttemptScreen(),
attemptResult: (context) => const AttemptResultScreen(),
};
}
}
+284
View File
@@ -0,0 +1,284 @@
import 'package:flutter/material.dart';
import 'package:practice_engine/practice_engine.dart';
import '../routes.dart';
import '../widgets/status_chip.dart';
class AttemptResultScreen extends StatefulWidget {
const AttemptResultScreen({super.key});
@override
State<AttemptResultScreen> createState() => _AttemptResultScreenState();
}
class _AttemptResultScreenState extends State<AttemptResultScreen> {
Deck? _deck;
AttemptResult? _result;
@override
void initState() {
super.initState();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_deck == null) {
// Get data from route arguments
final args = ModalRoute.of(context)?.settings.arguments as Map<String, dynamic>?;
_deck = args?['deck'] as Deck? ?? _createSampleDeck();
_result = args?['result'] as AttemptResult? ?? _createSampleResult();
}
}
Deck _createSampleDeck() {
return Deck(
id: 'sample',
title: 'Sample',
description: 'Sample',
questions: [],
config: const DeckConfig(),
);
}
AttemptResult _createSampleResult() {
return AttemptResult(
totalQuestions: 10,
correctCount: 7,
percentageCorrect: 70.0,
timeSpent: 300000,
incorrectQuestions: [],
allResults: [],
);
}
String _formatTime(int milliseconds) {
final seconds = milliseconds ~/ 1000;
final minutes = seconds ~/ 60;
final remainingSeconds = seconds % 60;
return '${minutes}m ${remainingSeconds}s';
}
void _repeatSameAttempt() {
if (_deck == null) return;
Navigator.pushReplacementNamed(context, Routes.attempt, arguments: _deck);
}
void _newAttempt() {
if (_deck == null) return;
Navigator.pushReplacementNamed(context, Routes.attempt, arguments: _deck);
}
void _done() {
if (_deck == null) return;
// Navigate back to deck overview with updated deck
Navigator.pushNamedAndRemoveUntil(
context,
Routes.deckOverview,
(route) => false,
arguments: _deck,
);
}
@override
Widget build(BuildContext context) {
if (_deck == null || _result == null) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Attempt Results'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Summary Card
Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Text(
'Results',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 24),
CircularProgressIndicator(
value: _result!.percentageCorrect / 100,
strokeWidth: 8,
),
const SizedBox(height: 16),
Text(
'${_result!.percentageCorrect.toStringAsFixed(1)}%',
style: Theme.of(context).textTheme.displayMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'${_result!.correctCount} of ${_result!.totalQuestions} correct',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_StatItem(
label: 'Time',
value: _formatTime(_result!.timeSpent),
icon: Icons.timer,
),
_StatItem(
label: 'Correct',
value: '${_result!.correctCount}',
icon: Icons.check_circle,
),
_StatItem(
label: 'Incorrect',
value: '${_result!.totalQuestions - _result!.correctCount}',
icon: Icons.cancel,
),
],
),
],
),
),
),
const SizedBox(height: 24),
// Incorrect Questions
if (_result!.incorrectQuestions.isNotEmpty) ...[
Text(
'Incorrect Questions',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
..._result!.incorrectQuestions.map((answerResult) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
answerResult.question.prompt,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
Row(
children: [
StatusChip(statusChange: answerResult.statusChange),
const Spacer(),
Text(
'Your answer: ${answerResult.question.answers[answerResult.userAnswerIndex]}',
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
Text(
'Correct answer: ${answerResult.question.answers[answerResult.question.correctAnswerIndex]}',
style: TextStyle(
color: Colors.green,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
}),
const SizedBox(height: 24),
],
// All Results with Status
Text(
'Question Status Changes',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
..._result!.allResults.map((answerResult) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
title: Text(answerResult.question.prompt),
subtitle: Text(
answerResult.isCorrect
? 'Correct'
: 'Incorrect - Selected: ${answerResult.question.answers[answerResult.userAnswerIndex]}',
),
trailing: StatusChip(statusChange: answerResult.statusChange),
),
);
}),
const SizedBox(height: 24),
// Action Buttons
FilledButton.icon(
onPressed: _repeatSameAttempt,
icon: const Icon(Icons.repeat),
label: const Text('Repeat Same Attempt'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _newAttempt,
icon: const Icon(Icons.refresh),
label: const Text('New Attempt'),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _done,
icon: const Icon(Icons.check),
label: const Text('Done'),
),
],
),
),
);
}
}
class _StatItem extends StatelessWidget {
final String label;
final String value;
final IconData icon;
const _StatItem({
required this.label,
required this.value,
required this.icon,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Icon(icon, size: 32),
const SizedBox(height: 8),
Text(
value,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
}
+268
View File
@@ -0,0 +1,268 @@
import 'package:flutter/material.dart';
import 'package:practice_engine/practice_engine.dart';
import '../routes.dart';
import '../widgets/question_card.dart';
import '../widgets/answer_option.dart';
class AttemptScreen extends StatefulWidget {
const AttemptScreen({super.key});
@override
State<AttemptScreen> createState() => _AttemptScreenState();
}
class _AttemptScreenState extends State<AttemptScreen> {
Deck? _deck;
Attempt? _attempt;
AttemptService? _attemptService;
int _currentQuestionIndex = 0;
int? _selectedAnswerIndex;
final Map<String, int> _answers = {};
final Map<String, bool> _manualOverrides = {};
@override
void initState() {
super.initState();
_attemptService = AttemptService();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_deck == null) {
// Get deck from route arguments
final args = ModalRoute.of(context)?.settings.arguments;
_deck = args is Deck ? args : _createSampleDeck();
_attempt = _attemptService!.createAttempt(deck: _deck!);
}
}
Deck _createSampleDeck() {
const config = DeckConfig();
final questions = List.generate(10, (i) {
return Question(
id: 'q$i',
prompt: 'Sample Question $i?',
answers: ['A', 'B', 'C', 'D'],
correctAnswerIndex: i % 4,
);
});
return Deck(
id: 'sample',
title: 'Sample',
description: 'Sample',
questions: questions,
config: config,
);
}
Question get _currentQuestion => _attempt!.questions[_currentQuestionIndex];
bool get _isLastQuestion => _currentQuestionIndex == _attempt!.questions.length - 1;
bool get _hasAnswer => _selectedAnswerIndex != null;
void _selectAnswer(int index) {
setState(() {
_selectedAnswerIndex = index;
});
if (_deck != null && _deck!.config.immediateFeedbackEnabled) {
// Show feedback immediately
}
}
void _submitAnswer() {
if (_selectedAnswerIndex == null) return;
_answers[_currentQuestion.id] = _selectedAnswerIndex!;
if (_isLastQuestion) {
_completeAttempt();
} else {
setState(() {
_currentQuestionIndex++;
_selectedAnswerIndex = null;
});
}
}
void _markAsKnown() {
if (_deck == null) return;
setState(() {
_manualOverrides[_currentQuestion.id] = false; // Not needs practice
_deck = DeckService.markQuestionAsKnown(
deck: _deck!,
questionId: _currentQuestion.id,
);
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Question marked as Known'),
backgroundColor: Colors.green,
),
);
}
void _markAsNeedsPractice() {
if (_deck == null) return;
setState(() {
_manualOverrides[_currentQuestion.id] = true;
_deck = DeckService.markQuestionAsNeedsPractice(
deck: _deck!,
questionId: _currentQuestion.id,
);
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Question marked as Needs Practice'),
),
);
}
void _completeAttempt() {
if (_deck == null || _attempt == null || _attemptService == null) return;
final result = _attemptService!.processAttempt(
deck: _deck!,
attempt: _attempt!,
answers: _answers,
manualOverrides: _manualOverrides,
endTime: DateTime.now().millisecondsSinceEpoch,
);
Navigator.pushReplacementNamed(
context,
Routes.attemptResult,
arguments: {
'deck': result.updatedDeck,
'result': result.result,
'attempt': _attempt,
},
);
}
@override
Widget build(BuildContext context) {
if (_deck == null || _attempt == null) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
title: Text('Attempt - ${_deck!.title}'),
),
body: Column(
children: [
// Progress Indicator
LinearProgressIndicator(
value: (_currentQuestionIndex + 1) / _attempt!.questions.length,
minHeight: 4,
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Question ${_currentQuestionIndex + 1} of ${_attempt!.questions.length}',
style: Theme.of(context).textTheme.bodyMedium,
),
if (_currentQuestion.isKnown)
Chip(
label: const Text('Known'),
avatar: const Icon(Icons.check_circle, size: 18),
),
],
),
),
// Question Card
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
QuestionCard(question: _currentQuestion),
const SizedBox(height: 24),
// Answer Options
...List.generate(
_currentQuestion.answers.length,
(index) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: AnswerOption(
text: _currentQuestion.answers[index],
isSelected: _selectedAnswerIndex == index,
onTap: () => _selectAnswer(index),
isCorrect: _deck != null && _deck!.config.immediateFeedbackEnabled &&
_selectedAnswerIndex == index
? index == _currentQuestion.correctAnswerIndex
: null,
),
),
),
const SizedBox(height: 24),
// Manual Override Buttons
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Manual Override',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _markAsKnown,
icon: const Icon(Icons.check_circle),
label: const Text('Mark as Known'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _markAsNeedsPractice,
icon: const Icon(Icons.school),
label: const Text('Needs Practice'),
),
),
],
),
],
),
),
),
],
),
),
),
// Submit/Next Button
Padding(
padding: const EdgeInsets.all(16),
child: FilledButton(
onPressed: _hasAnswer ? _submitAnswer : null,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: Text(_isLastQuestion ? 'Complete Attempt' : 'Next Question'),
),
),
],
),
);
}
}
+241
View File
@@ -0,0 +1,241 @@
import 'package:flutter/material.dart';
import 'package:practice_engine/practice_engine.dart';
class DeckConfigScreen extends StatefulWidget {
const DeckConfigScreen({super.key});
@override
State<DeckConfigScreen> createState() => _DeckConfigScreenState();
}
class _DeckConfigScreenState extends State<DeckConfigScreen> {
Deck? _deck;
DeckConfig? _config;
late TextEditingController _consecutiveController;
late TextEditingController _attemptSizeController;
late TextEditingController _priorityIncreaseController;
late TextEditingController _priorityDecreaseController;
late bool _immediateFeedback;
@override
void initState() {
super.initState();
_deck = null;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_deck == null) {
// Get deck from route arguments
final args = ModalRoute.of(context)?.settings.arguments;
_deck = args is Deck ? args : _createSampleDeck();
_config = _deck!.config;
_consecutiveController = TextEditingController(
text: _config!.requiredConsecutiveCorrect.toString(),
);
_attemptSizeController = TextEditingController(
text: _config!.defaultAttemptSize.toString(),
);
_priorityIncreaseController = TextEditingController(
text: _config!.priorityIncreaseOnIncorrect.toString(),
);
_priorityDecreaseController = TextEditingController(
text: _config!.priorityDecreaseOnCorrect.toString(),
);
_immediateFeedback = _config!.immediateFeedbackEnabled;
}
}
Deck _createSampleDeck() {
return Deck(
id: 'sample',
title: 'Sample',
description: 'Sample',
questions: [],
config: const DeckConfig(),
);
}
@override
void dispose() {
_consecutiveController.dispose();
_attemptSizeController.dispose();
_priorityIncreaseController.dispose();
_priorityDecreaseController.dispose();
super.dispose();
}
void _save() {
if (_deck == null || _config == null) return;
final consecutive = int.tryParse(_consecutiveController.text);
final attemptSize = int.tryParse(_attemptSizeController.text);
final priorityIncrease = int.tryParse(_priorityIncreaseController.text);
final priorityDecrease = int.tryParse(_priorityDecreaseController.text);
if (consecutive == null ||
attemptSize == null ||
priorityIncrease == null ||
priorityDecrease == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please enter valid numbers for all fields'),
backgroundColor: Colors.red,
),
);
return;
}
if (consecutive < 1 ||
attemptSize < 1 ||
priorityIncrease < 0 ||
priorityDecrease < 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Values must be positive (priority changes >= 0)'),
backgroundColor: Colors.red,
),
);
return;
}
final updatedConfig = _config!.copyWith(
requiredConsecutiveCorrect: consecutive,
defaultAttemptSize: attemptSize,
priorityIncreaseOnIncorrect: priorityIncrease,
priorityDecreaseOnCorrect: priorityDecrease,
immediateFeedbackEnabled: _immediateFeedback,
);
final updatedDeck = _deck!.copyWith(config: updatedConfig);
Navigator.pop(context, updatedDeck);
}
void _cancel() {
Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
if (_deck == null || _config == null) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Deck Configuration'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Practice Settings',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 24),
// Required Consecutive Correct
TextField(
controller: _consecutiveController,
decoration: const InputDecoration(
labelText: 'Required Consecutive Correct',
helperText: 'Number of correct answers in a row to mark as known',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
// Default Attempt Size
TextField(
controller: _attemptSizeController,
decoration: const InputDecoration(
labelText: 'Default Questions Per Attempt',
helperText: 'Number of questions to include in each attempt',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
// Priority Increase
TextField(
controller: _priorityIncreaseController,
decoration: const InputDecoration(
labelText: 'Priority Increase on Incorrect',
helperText: 'Priority points added when answered incorrectly',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
// Priority Decrease
TextField(
controller: _priorityDecreaseController,
decoration: const InputDecoration(
labelText: 'Priority Decrease on Correct',
helperText: 'Priority points removed when answered correctly',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
// Immediate Feedback Toggle
SwitchListTile(
title: const Text('Immediate Feedback'),
subtitle: const Text(
'Show correct/incorrect immediately after answering',
),
value: _immediateFeedback,
onChanged: (value) {
setState(() {
_immediateFeedback = value;
});
},
),
],
),
),
),
const SizedBox(height: 24),
// Action Buttons
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _cancel,
child: const Text('Cancel'),
),
),
const SizedBox(width: 16),
Expanded(
child: FilledButton(
onPressed: _save,
child: const Text('Save'),
),
),
],
),
],
),
),
);
}
}
+301
View File
@@ -0,0 +1,301 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:practice_engine/practice_engine.dart';
import '../routes.dart';
class DeckImportScreen extends StatefulWidget {
const DeckImportScreen({super.key});
@override
State<DeckImportScreen> createState() => _DeckImportScreenState();
}
class _DeckImportScreenState extends State<DeckImportScreen> {
final TextEditingController _jsonController = TextEditingController();
String? _errorMessage;
bool _isLoading = false;
@override
void dispose() {
_jsonController.dispose();
super.dispose();
}
Deck? _parseDeckFromJson(String jsonString) {
try {
final Map<String, dynamic> json = jsonDecode(jsonString);
// 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,
);
// Parse questions
final questionsJson = json['questions'] as List<dynamic>? ?? [];
final questions = questionsJson.map((qJson) {
final questionMap = qJson as Map<String, dynamic>;
return Question(
id: questionMap['id'] as String? ?? '',
prompt: questionMap['prompt'] as String? ?? '',
answers: (questionMap['answers'] as List<dynamic>?)
?.map((e) => e.toString())
.toList() ??
[],
correctAnswerIndex: questionMap['correctAnswerIndex'] as int? ?? 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();
// 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,
);
} catch (e) {
throw FormatException('Invalid JSON format: $e');
}
}
void _importDeck() {
setState(() {
_errorMessage = null;
_isLoading = true;
});
try {
if (_jsonController.text.trim().isEmpty) {
throw FormatException('Please enter JSON data');
}
final deck = _parseDeckFromJson(_jsonController.text.trim());
if (deck == null) {
throw FormatException('Failed to parse deck');
}
if (deck.questions.isEmpty) {
throw FormatException('Deck must contain at least one question');
}
// Navigate to deck overview with the imported deck
Navigator.pushReplacementNamed(
context,
Routes.deckOverview,
arguments: deck,
);
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
void _loadSampleDeck() {
final sampleJson = {
'id': 'sample-deck',
'title': 'Sample Practice Deck',
'description': 'This is a sample deck for practicing. It contains various questions to help you learn.',
'config': {
'requiredConsecutiveCorrect': 3,
'defaultAttemptSize': 10,
'priorityIncreaseOnIncorrect': 5,
'priorityDecreaseOnCorrect': 2,
'immediateFeedbackEnabled': true,
},
'questions': List.generate(20, (i) {
return {
'id': 'q$i',
'prompt': 'Sample Question $i?',
'answers': ['Answer A', 'Answer B', 'Answer C', 'Answer D'],
'correctAnswerIndex': i % 4,
'isKnown': i < 5,
};
}),
};
_jsonController.text = const JsonEncoder.withIndent(' ').convert(sampleJson);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Import Deck'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Import Deck from JSON',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Paste your deck JSON below. The format should include id, title, description, config, and questions.',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
const SizedBox(height: 16),
// JSON Input
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Deck JSON',
style: Theme.of(context).textTheme.titleMedium,
),
TextButton.icon(
onPressed: _loadSampleDeck,
icon: const Icon(Icons.description),
label: const Text('Load Sample'),
),
],
),
const SizedBox(height: 8),
TextField(
controller: _jsonController,
maxLines: 15,
decoration: InputDecoration(
hintText: 'Paste JSON here...',
border: const OutlineInputBorder(),
errorText: _errorMessage,
),
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
],
),
),
),
const SizedBox(height: 24),
// Error Message
if (_errorMessage != null)
Card(
color: Colors.red.shade50,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.error, color: Colors.red.shade700),
const SizedBox(width: 12),
Expanded(
child: Text(
_errorMessage!,
style: TextStyle(color: Colors.red.shade700),
),
),
],
),
),
),
if (_errorMessage != null) const SizedBox(height: 16),
// Import Button
FilledButton.icon(
onPressed: _isLoading ? null : _importDeck,
icon: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.upload),
label: Text(_isLoading ? 'Importing...' : 'Import Deck'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
const SizedBox(height: 16),
// JSON Format Help
ExpansionTile(
title: const Text('JSON Format Help'),
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Required fields:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text('• id: string'),
const Text('• title: string'),
const Text('• description: string'),
const Text('• questions: array of question objects'),
const SizedBox(height: 16),
const Text(
'Question object format:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text('• id: string (required)'),
const Text('• prompt: string (required)'),
const Text('• answers: array of strings (required)'),
const Text('• correctAnswerIndex: number (required)'),
const Text('• isKnown: boolean (optional)'),
const Text('• consecutiveCorrect: number (optional)'),
const Text('• priorityPoints: number (optional)'),
const SizedBox(height: 16),
const Text(
'Config object (optional):',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text('• requiredConsecutiveCorrect: number'),
const Text('• defaultAttemptSize: number'),
const Text('• priorityIncreaseOnIncorrect: number'),
const Text('• priorityDecreaseOnCorrect: number'),
const Text('• immediateFeedbackEnabled: boolean'),
],
),
),
],
),
],
),
),
);
}
}
+299
View File
@@ -0,0 +1,299 @@
import 'package:flutter/material.dart';
import 'package:practice_engine/practice_engine.dart';
import '../routes.dart';
class DeckOverviewScreen extends StatefulWidget {
const DeckOverviewScreen({super.key});
@override
State<DeckOverviewScreen> createState() => _DeckOverviewScreenState();
}
class _DeckOverviewScreenState extends State<DeckOverviewScreen> {
Deck? _deck;
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Get deck from route arguments or create sample
final args = ModalRoute.of(context)?.settings.arguments;
if (args is Deck) {
setState(() {
_deck = args;
});
} else if (_deck == null) {
// Only create sample if we don't have a deck yet
setState(() {
_deck = _createSampleDeck();
});
}
}
@override
void initState() {
super.initState();
}
Deck _createSampleDeck() {
const config = DeckConfig();
final questions = List.generate(20, (i) {
return Question(
id: 'q$i',
prompt: 'Sample Question $i?',
answers: ['Answer A', 'Answer B', 'Answer C', 'Answer D'],
correctAnswerIndex: i % 4,
isKnown: i < 5, // First 5 are known
);
});
return Deck(
id: 'sample-deck',
title: 'Sample Practice Deck',
description: 'This is a sample deck for practicing. It contains various questions to help you learn.',
questions: questions,
config: config,
);
}
void _startAttempt() {
if (_deck == null || _deck!.questions.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Cannot start attempt: No questions in deck'),
backgroundColor: Colors.red,
),
);
return;
}
Navigator.pushNamed(
context,
Routes.attempt,
arguments: _deck!,
);
}
void _openConfig() {
if (_deck == null) return;
Navigator.pushNamed(context, Routes.deckConfig, arguments: _deck)
.then((updatedDeck) {
if (updatedDeck != null && updatedDeck is Deck) {
setState(() {
_deck = updatedDeck;
});
}
});
}
Future<void> _resetDeck() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Reset Deck Progress'),
content: const Text(
'Are you sure you want to reset all progress? This will reset streaks, known status, and priorities for all questions.',
),
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('Reset'),
),
],
),
);
if (confirmed == true && _deck != null) {
setState(() {
_deck = DeckService.resetDeck(deck: _deck!, resetAttemptCounts: false);
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Deck progress has been reset'),
backgroundColor: Colors.green,
),
);
}
}
}
@override
Widget build(BuildContext context) {
if (_deck == null) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
title: Text(_deck!.title),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Reset Progress',
onPressed: _resetDeck,
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Deck Description
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Description',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
_deck!.description,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
const SizedBox(height: 24),
// Practice Progress
Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Text(
'Practice Progress',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 24),
CircularProgressIndicator(
value: _deck!.practicePercentage / 100,
strokeWidth: 8,
),
const SizedBox(height: 16),
Text(
'${_deck!.practicePercentage.toStringAsFixed(1)}%',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'${_deck!.knownCount} of ${_deck!.numberOfQuestions} questions known',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
const SizedBox(height: 24),
// Statistics
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Statistics',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_StatItem(
label: 'Total Questions',
value: '${_deck!.numberOfQuestions}',
icon: Icons.quiz,
),
_StatItem(
label: 'Known',
value: '${_deck!.knownCount}',
icon: Icons.check_circle,
),
_StatItem(
label: 'Needs Practice',
value: '${_deck!.numberOfQuestions - _deck!.knownCount}',
icon: Icons.school,
),
],
),
],
),
),
),
const SizedBox(height: 24),
// Action Buttons
FilledButton.icon(
onPressed: _startAttempt,
icon: const Icon(Icons.play_arrow),
label: const Text('Start Attempt'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _openConfig,
icon: const Icon(Icons.settings),
label: const Text('Configure Deck'),
),
],
),
),
);
}
}
class _StatItem extends StatelessWidget {
final String label;
final String value;
final IconData icon;
const _StatItem({
required this.label,
required this.value,
required this.icon,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Icon(icon, size: 32),
const SizedBox(height: 8),
Text(
value,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
}
+62
View File
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
class AppTheme {
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.light,
),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
chipTheme: ChipThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
);
}
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
chipTheme: ChipThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
);
}
}
+81
View File
@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
class AnswerOption extends StatelessWidget {
final String text;
final bool isSelected;
final VoidCallback onTap;
final bool? isCorrect;
const AnswerOption({
super.key,
required this.text,
required this.isSelected,
required this.onTap,
this.isCorrect,
});
@override
Widget build(BuildContext context) {
Color? backgroundColor;
Color? borderColor;
IconData? icon;
if (isCorrect != null) {
if (isCorrect == true) {
backgroundColor = Colors.green.withValues(alpha: 0.1);
borderColor = Colors.green;
icon = Icons.check_circle;
} else {
backgroundColor = Colors.red.withValues(alpha: 0.1);
borderColor = Colors.red;
icon = Icons.cancel;
}
} else if (isSelected) {
backgroundColor = Theme.of(context).colorScheme.primaryContainer;
borderColor = Theme.of(context).colorScheme.primary;
}
return Card(
color: backgroundColor,
elevation: isSelected ? 4 : 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: borderColor != null
? BorderSide(color: borderColor, width: 2)
: BorderSide.none,
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
if (icon != null) ...[
Icon(icon, color: borderColor),
const SizedBox(width: 12),
],
Expanded(
child: Text(
text,
style: Theme.of(context).textTheme.bodyLarge,
),
),
if (isSelected && icon == null)
Icon(
Icons.radio_button_checked,
color: Theme.of(context).colorScheme.primary,
)
else if (!isSelected)
Icon(
Icons.radio_button_unchecked,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
),
],
),
),
),
);
}
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
class PracticeProgressIndicator extends StatelessWidget {
final double percentage;
const PracticeProgressIndicator({
super.key,
required this.percentage,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
CircularProgressIndicator(
value: percentage / 100,
strokeWidth: 8,
),
const SizedBox(height: 8),
Text(
'${percentage.toStringAsFixed(1)}%',
style: Theme.of(context).textTheme.titleLarge,
),
],
);
}
}
+48
View File
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:practice_engine/practice_engine.dart';
class QuestionCard extends StatelessWidget {
final Question question;
const QuestionCard({
super.key,
required this.question,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.quiz,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Question',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
Text(
question.prompt,
style: Theme.of(context).textTheme.titleLarge,
),
],
),
),
);
}
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:practice_engine/practice_engine.dart';
class StatusChip extends StatelessWidget {
final QuestionStatusChange statusChange;
const StatusChip({
super.key,
required this.statusChange,
});
@override
Widget build(BuildContext context) {
String label;
IconData icon;
Color color;
switch (statusChange) {
case QuestionStatusChange.improved:
label = 'Improved';
icon = Icons.trending_up;
color = Colors.green;
break;
case QuestionStatusChange.regressed:
label = 'Regressed';
icon = Icons.trending_down;
color = Colors.red;
break;
case QuestionStatusChange.unchanged:
label = 'Unchanged';
icon = Icons.remove;
color = Colors.grey;
break;
}
return Chip(
label: Text(label),
avatar: Icon(icon, size: 18, color: color),
backgroundColor: color.withValues(alpha: 0.1),
side: BorderSide(color: color),
);
}
}