gitea 2 months ago
parent 5644bfde61
commit 5362b21f08

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,
);
}
}

@ -15,8 +15,8 @@ class DeckyApp extends StatelessWidget {
title: 'Decky - Practice Engine',
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system,
initialRoute: Routes.deckImport,
themeMode: ThemeMode.dark,
initialRoute: Routes.deckList,
routes: Routes.routes,
debugShowCheckedModeBanner: false,
);

@ -1,22 +1,31 @@
import 'package:flutter/material.dart';
import 'screens/deck_list_screen.dart';
import 'screens/deck_import_screen.dart';
import 'screens/deck_overview_screen.dart';
import 'screens/deck_config_screen.dart';
import 'screens/deck_edit_screen.dart';
import 'screens/deck_create_screen.dart';
import 'screens/attempt_screen.dart';
import 'screens/attempt_result_screen.dart';
class Routes {
static const String deckImport = '/';
static const String deckList = '/';
static const String deckImport = '/deck-import';
static const String deckOverview = '/deck-overview';
static const String deckConfig = '/deck-config';
static const String deckEdit = '/deck-edit';
static const String deckCreate = '/deck-create';
static const String attempt = '/attempt';
static const String attemptResult = '/attempt-result';
static Map<String, WidgetBuilder> get routes {
return {
deckList: (context) => const DeckListScreen(),
deckImport: (context) => const DeckImportScreen(),
deckOverview: (context) => const DeckOverviewScreen(),
deckConfig: (context) => const DeckConfigScreen(),
deckEdit: (context) => const DeckEditScreen(),
deckCreate: (context) => const DeckCreateScreen(),
attempt: (context) => const AttemptScreen(),
attemptResult: (context) => const AttemptResultScreen(),
};

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:practice_engine/practice_engine.dart';
import '../routes.dart';
import '../widgets/status_chip.dart';
import '../services/deck_storage.dart';
class AttemptResultScreen extends StatefulWidget {
const AttemptResultScreen({super.key});
@ -13,6 +14,7 @@ class AttemptResultScreen extends StatefulWidget {
class _AttemptResultScreenState extends State<AttemptResultScreen> {
Deck? _deck;
AttemptResult? _result;
final DeckStorage _deckStorage = DeckStorage();
@override
void initState() {
@ -70,12 +72,13 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
void _done() {
if (_deck == null) return;
// Navigate back to deck overview with updated deck
// Save the updated deck to storage
_deckStorage.saveDeck(_deck!);
// Navigate back to deck list
Navigator.pushNamedAndRemoveUntil(
context,
Routes.deckOverview,
Routes.deckList,
(route) => false,
arguments: _deck,
);
}
@ -174,25 +177,25 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
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]}',
'Your answer${answerResult.userAnswerIndices.length > 1 ? 's' : ''}: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
style: TextStyle(
color: Colors.green,
color: Colors.red,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
Text(
'Correct answer${answerResult.question.correctIndices.length > 1 ? 's' : ''}: ${answerResult.question.correctIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
style: TextStyle(
color: Colors.green,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
}),
@ -213,7 +216,7 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
subtitle: Text(
answerResult.isCorrect
? 'Correct'
: 'Incorrect - Selected: ${answerResult.question.answers[answerResult.userAnswerIndex]}',
: 'Incorrect - Selected: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
),
trailing: StatusChip(statusChange: answerResult.statusChange),
),

@ -1,8 +1,65 @@
import 'package:flutter/material.dart';
import 'dart:math' as math;
import 'dart:async';
import 'dart:ui';
import 'package:practice_engine/practice_engine.dart';
import '../routes.dart';
import '../widgets/question_card.dart';
import '../widgets/answer_option.dart';
import '../services/deck_storage.dart';
/// Custom 3D cube page transformer
class CubePageTransitionsBuilder extends PageTransitionsBuilder {
const CubePageTransitionsBuilder();
@override
Widget buildTransitions<T extends Object?>(
PageRoute<T> route,
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return _CubeTransition(
animation: animation,
child: child,
);
}
}
class _CubeTransition extends StatelessWidget {
final Animation<double> animation;
final Widget child;
const _CubeTransition({
required this.animation,
required this.child,
});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
final value = animation.value;
final angle = (1.0 - value) * math.pi / 2;
final opacity = value < 0.5 ? 0.0 : (value - 0.5) * 2;
return Transform(
alignment: Alignment.centerLeft,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateY(angle),
child: Opacity(
opacity: opacity.clamp(0.0, 1.0),
child: child,
),
);
},
child: child,
);
}
}
class AttemptScreen extends StatefulWidget {
const AttemptScreen({super.key});
@ -16,14 +73,21 @@ class _AttemptScreenState extends State<AttemptScreen> {
Attempt? _attempt;
AttemptService? _attemptService;
int _currentQuestionIndex = 0;
int? _selectedAnswerIndex;
final Map<String, int> _answers = {};
final Map<String, bool> _manualOverrides = {};
int? _selectedAnswerIndex; // For single answer questions (backward compatibility)
Set<int> _selectedAnswerIndices = {}; // For multiple answer questions
Map<String, dynamic> _answers = {}; // Can store int or List<int>
Map<String, bool> _manualOverrides = {};
final DeckStorage _deckStorage = DeckStorage();
late PageController _pageController;
Timer? _timer;
int _remainingSeconds = 0;
int _startTime = 0;
@override
void initState() {
super.initState();
_attemptService = AttemptService();
_pageController = PageController();
}
@override
@ -32,11 +96,100 @@ class _AttemptScreenState extends State<AttemptScreen> {
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!);
bool? includeKnown;
bool? resumeAttempt;
if (args is Map<String, dynamic>) {
_deck = args['deck'] as Deck? ?? _createSampleDeck();
includeKnown = args['includeKnown'] as bool?;
resumeAttempt = args['resumeAttempt'] as bool? ?? false;
} else if (args is Deck) {
_deck = args;
resumeAttempt = false;
} else {
_deck = _createSampleDeck();
resumeAttempt = false;
}
// Check if we should resume an incomplete attempt
if (resumeAttempt == true && _deck!.incompleteAttempt != null) {
final incomplete = _deck!.incompleteAttempt!;
_attempt = incomplete.toAttempt(_deck!.questions);
_currentQuestionIndex = incomplete.currentQuestionIndex;
_answers = Map<String, dynamic>.from(incomplete.answers);
_manualOverrides = Map<String, bool>.from(incomplete.manualOverrides);
// Restore selected answer for current question
_loadQuestionState();
// Initialize PageController to the current question index
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_pageController.hasClients) {
_pageController.jumpToPage(_currentQuestionIndex);
}
});
} else {
_attempt = _attemptService!.createAttempt(
deck: _deck!,
includeKnown: includeKnown,
);
}
// Initialize timer if time limit is set
if (_deck!.config.timeLimitSeconds != null) {
if (resumeAttempt == true && _deck!.incompleteAttempt != null) {
// Use remaining time from incomplete attempt
if (_deck!.incompleteAttempt!.remainingSeconds != null) {
_remainingSeconds = _deck!.incompleteAttempt!.remainingSeconds!;
} else {
// Fallback: calculate remaining time when resuming (for backward compatibility)
final pausedAt = _deck!.incompleteAttempt!.pausedAt;
final elapsedSeconds = (DateTime.now().millisecondsSinceEpoch - pausedAt) ~/ 1000;
_remainingSeconds = (_deck!.config.timeLimitSeconds! - elapsedSeconds).clamp(0, _deck!.config.timeLimitSeconds!);
}
} else {
_startTime = DateTime.now().millisecondsSinceEpoch;
_remainingSeconds = _deck!.config.timeLimitSeconds!;
}
_startTimer();
}
}
}
void _startTimer() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) {
timer.cancel();
return;
}
setState(() {
if (_remainingSeconds > 0) {
_remainingSeconds--;
} else {
// Time expired
timer.cancel();
_handleTimeExpired();
}
});
});
}
void _handleTimeExpired() {
// Auto-submit the attempt when time expires
if (_attempt != null && _deck != null) {
_completeAttempt();
}
}
@override
void dispose() {
_timer?.cancel();
_pageController.dispose();
super.dispose();
}
Deck _createSampleDeck() {
const config = DeckConfig();
final questions = List.generate(10, (i) {
@ -44,7 +197,7 @@ class _AttemptScreenState extends State<AttemptScreen> {
id: 'q$i',
prompt: 'Sample Question $i?',
answers: ['A', 'B', 'C', 'D'],
correctAnswerIndex: i % 4,
correctAnswerIndices: [i % 4],
);
});
return Deck(
@ -58,84 +211,180 @@ class _AttemptScreenState extends State<AttemptScreen> {
Question get _currentQuestion => _attempt!.questions[_currentQuestionIndex];
bool get _isLastQuestion => _currentQuestionIndex == _attempt!.questions.length - 1;
bool get _hasAnswer => _selectedAnswerIndex != null;
bool get _hasMultipleCorrect => _currentQuestion.hasMultipleCorrectAnswers;
bool get _hasAnswer {
if (_hasMultipleCorrect) {
return _selectedAnswerIndices.isNotEmpty;
} else {
return _selectedAnswerIndex != null;
}
}
/// Returns true if at least one question has been answered (progress has been made)
bool get _hasAnyProgress => _answers.isNotEmpty || _manualOverrides.isNotEmpty;
void _goToPreviousQuestion() {
if (_currentQuestionIndex > 0) {
// Save current answer if any
if (_hasAnswer) {
if (_hasMultipleCorrect) {
_answers[_currentQuestion.id] = _selectedAnswerIndices.toList()..sort();
} else {
_answers[_currentQuestion.id] = _selectedAnswerIndex!;
}
}
_pageController.previousPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
void _goToNextQuestion() {
if (_currentQuestionIndex < _attempt!.questions.length - 1) {
// Save current answer if any
if (_hasAnswer) {
if (_hasMultipleCorrect) {
_answers[_currentQuestion.id] = _selectedAnswerIndices.toList()..sort();
} else {
_answers[_currentQuestion.id] = _selectedAnswerIndex!;
}
}
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
}
String _formatTime(int seconds) {
final hours = seconds ~/ 3600;
final minutes = (seconds % 3600) ~/ 60;
final secs = seconds % 60;
if (hours > 0) {
return '${hours}h ${minutes}m ${secs}s';
} else if (minutes > 0) {
return '${minutes}m ${secs}s';
} else {
return '${secs}s';
}
}
void _selectAnswer(int index) {
void _onPageChanged(int index) {
setState(() {
_selectedAnswerIndex = index;
_currentQuestionIndex = index;
_loadQuestionState();
});
}
void _loadQuestionState() {
// Load saved answer for current question
final currentQuestionId = _currentQuestion.id;
final savedAnswer = _answers[currentQuestionId];
if (_deck != null && _deck!.config.immediateFeedbackEnabled) {
// Show feedback immediately
if (savedAnswer != null) {
if (savedAnswer is int) {
_selectedAnswerIndex = savedAnswer;
_selectedAnswerIndices.clear();
} else if (savedAnswer is List<int>) {
_selectedAnswerIndices = savedAnswer.toSet();
_selectedAnswerIndex = null;
}
} else {
_selectedAnswerIndex = null;
_selectedAnswerIndices.clear();
}
}
void _submitAnswer() {
if (_selectedAnswerIndex == null) return;
if (!_hasAnswer) return;
_answers[_currentQuestion.id] = _selectedAnswerIndex!;
// Store answer(s) based on question type
if (_hasMultipleCorrect) {
_answers[_currentQuestion.id] = _selectedAnswerIndices.toList()..sort();
} else {
_answers[_currentQuestion.id] = _selectedAnswerIndex!;
}
if (_isLastQuestion) {
_completeAttempt();
} else {
setState(() {
_currentQuestionIndex++;
_selectedAnswerIndex = null;
});
_goToNextQuestion();
}
}
void _markAsKnown() {
if (_deck == null) return;
setState(() {
_manualOverrides[_currentQuestion.id] = false; // Not needs practice
_deck = DeckService.markQuestionAsKnown(
deck: _deck!,
questionId: _currentQuestion.id,
);
});
void _saveForLater() {
if (_deck == null || _attempt == null) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Question marked as Known'),
backgroundColor: Colors.green,
),
// Save current answer if selected
if (_hasAnswer) {
if (_hasMultipleCorrect) {
_answers[_currentQuestion.id] = _selectedAnswerIndices.toList()..sort();
} else {
_answers[_currentQuestion.id] = _selectedAnswerIndex!;
}
}
// Create incomplete attempt
final incompleteAttempt = IncompleteAttempt(
attemptId: _attempt!.id,
questionIds: _attempt!.questions.map((q) => q.id).toList(),
startTime: _attempt!.startTime,
currentQuestionIndex: _currentQuestionIndex,
answers: _answers,
manualOverrides: _manualOverrides,
pausedAt: DateTime.now().millisecondsSinceEpoch,
remainingSeconds: _deck!.config.timeLimitSeconds != null ? _remainingSeconds : null,
);
}
void _markAsNeedsPractice() {
if (_deck == null) return;
setState(() {
_manualOverrides[_currentQuestion.id] = true;
_deck = DeckService.markQuestionAsNeedsPractice(
deck: _deck!,
questionId: _currentQuestion.id,
);
});
// Update deck with incomplete attempt
final updatedDeck = _deck!.copyWith(incompleteAttempt: incompleteAttempt);
_deckStorage.saveDeck(updatedDeck);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Question marked as Needs Practice'),
content: Text('Attempt saved. You can continue later.'),
backgroundColor: Colors.blue,
duration: Duration(seconds: 2),
),
);
Navigator.pop(context);
}
void _completeAttempt() {
if (_deck == null || _attempt == null || _attemptService == null) return;
final endTime = DateTime.now().millisecondsSinceEpoch;
final result = _attemptService!.processAttempt(
deck: _deck!,
attempt: _attempt!,
answers: _answers,
manualOverrides: _manualOverrides,
endTime: DateTime.now().millisecondsSinceEpoch,
endTime: endTime,
);
// Add attempt to history
final historyEntry = AttemptHistoryEntry.fromAttemptResult(
result: result.result,
timestamp: endTime,
);
final updatedDeckWithHistory = result.updatedDeck.copyWith(
attemptHistory: [...result.updatedDeck.attemptHistory, historyEntry],
clearIncompleteAttempt: true, // Clear incomplete attempt when completed
);
// Save the updated deck to storage
_deckStorage.saveDeck(updatedDeckWithHistory);
Navigator.pushReplacementNamed(
context,
Routes.attemptResult,
arguments: {
'deck': result.updatedDeck,
'deck': updatedDeckWithHistory,
'result': result.result,
'attempt': _attempt,
},
@ -150,18 +399,91 @@ class _AttemptScreenState extends State<AttemptScreen> {
);
}
return Scaffold(
appBar: AppBar(
title: Text('Attempt - ${_deck!.title}'),
),
body: Column(
children: [
// Progress Indicator
LinearProgressIndicator(
value: (_currentQuestionIndex + 1) / _attempt!.questions.length,
minHeight: 4,
return PopScope(
canPop: false,
onPopInvoked: (bool didPop) async {
if (didPop) return;
// Only prompt to save if there's actual progress (at least one question answered)
if (!_hasAnyProgress) {
// No progress made, just allow exit without prompt
if (mounted) {
Navigator.of(context).pop();
}
return;
}
// Ask if user wants to save for later
final shouldSave = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Exit Attempt?'),
content: const Text(
'Your progress will be lost. Would you like to save this attempt to continue later?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Discard'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Save for Later'),
),
],
),
const SizedBox(height: 8),
);
if (!mounted) return;
if (shouldSave == true) {
_saveForLater();
// _saveForLater will handle navigation
} else if (shouldSave == false) {
// User chose to discard, allow pop
Navigator.of(context).pop();
}
// If cancelled (shouldSave == null), don't pop
},
child: Scaffold(
appBar: AppBar(
title: Text('Attempt - ${_deck!.title}'),
actions: [
// Only show "Continue Later" button if there's progress
if (_hasAnyProgress)
TextButton.icon(
onPressed: _saveForLater,
icon: const Icon(Icons.pause),
label: const Text('Continue Later'),
),
],
),
body: Column(
children: [
// Time Limit Countdown Bar (if time limit is set)
if (_deck!.config.timeLimitSeconds != null)
Container(
height: 6,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: _remainingSeconds / _deck!.config.timeLimitSeconds!,
child: Container(
decoration: BoxDecoration(
color: _remainingSeconds <= 60
? Colors.red
: _remainingSeconds <= 300
? Colors.orange
: Colors.green,
),
),
),
),
// Progress Indicator
LinearProgressIndicator(
value: (_currentQuestionIndex + 1) / _attempt!.questions.length,
minHeight: 4,
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
@ -171,97 +493,299 @@ class _AttemptScreenState extends State<AttemptScreen> {
'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),
),
Row(
children: [
if (_deck!.config.timeLimitSeconds != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _remainingSeconds <= 60
? Colors.red.withValues(alpha: 0.2)
: _remainingSeconds <= 300
? Colors.orange.withValues(alpha: 0.2)
: Colors.green.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: Text(
_formatTime(_remainingSeconds),
style: TextStyle(
fontWeight: FontWeight.bold,
color: _remainingSeconds <= 60
? Colors.red
: _remainingSeconds <= 300
? Colors.orange
: Colors.green,
),
),
),
if (_deck!.config.timeLimitSeconds != null && _currentQuestion.isKnown)
const SizedBox(width: 8),
if (_currentQuestion.isKnown)
const Chip(
label: Text('Known'),
avatar: Icon(Icons.check_circle, size: 18),
),
],
),
],
),
),
// Question Card
// Question Card with PageView for 3D transitions
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,
child: PageView.builder(
controller: _pageController,
onPageChanged: _onPageChanged,
itemCount: _attempt!.questions.length,
physics: const BouncingScrollPhysics(), // Always allow swiping, validation happens in onPageChanged
itemBuilder: (context, index) {
final question = _attempt!.questions[index];
final isMultipleCorrect = question.hasMultipleCorrectAnswers;
final savedAnswer = _answers[question.id];
// Determine selected answers for this question
int? selectedIndex;
Set<int> selectedIndices = {};
if (savedAnswer != null) {
if (savedAnswer is int) {
selectedIndex = savedAnswer;
} else if (savedAnswer is List<int>) {
selectedIndices = savedAnswer.toSet();
}
}
return AnimatedBuilder(
animation: _pageController,
builder: (context, child) {
double value = 1.0;
double angle = 0.0;
if (_pageController.position.haveDimensions) {
value = _pageController.page! - index;
value = (1 - (value.abs() * 0.5)).clamp(0.0, 1.0);
// Calculate rotation angle for 3D cube effect
final pageOffset = _pageController.page! - index;
if (pageOffset.abs() < 1.0) {
angle = pageOffset * math.pi / 2;
} else if (pageOffset < 0) {
angle = -math.pi / 2;
} else {
angle = math.pi / 2;
}
}
return Transform(
alignment: Alignment.centerLeft,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateY(angle),
child: Opacity(
opacity: value.clamp(0.0, 1.0),
child: child,
),
),
),
);
},
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
QuestionCard(question: question),
const SizedBox(height: 24),
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,
// Answer Options
...List.generate(
question.answers.length,
(answerIndex) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: AnswerOption(
text: question.answers[answerIndex],
isSelected: isMultipleCorrect
? selectedIndices.contains(answerIndex)
: selectedIndex == answerIndex,
onTap: () {
// Update selection for this question
setState(() {
if (isMultipleCorrect) {
if (selectedIndices.contains(answerIndex)) {
selectedIndices.remove(answerIndex);
} else {
selectedIndices.add(answerIndex);
}
_answers[question.id] = selectedIndices.toList()..sort();
} else {
selectedIndex = answerIndex;
_answers[question.id] = selectedIndex;
}
// Update current question state if viewing it
if (index == _currentQuestionIndex) {
if (isMultipleCorrect) {
_selectedAnswerIndices = selectedIndices;
_selectedAnswerIndex = null;
} else {
_selectedAnswerIndex = selectedIndex;
_selectedAnswerIndices.clear();
}
}
});
},
isCorrect: _deck != null && _deck!.config.immediateFeedbackEnabled &&
(isMultipleCorrect
? selectedIndices.contains(answerIndex)
: selectedIndex == answerIndex)
? question.isCorrectAnswer(answerIndex)
: null,
isMultipleChoice: isMultipleCorrect,
),
const SizedBox(height: 12),
Row(
),
),
const SizedBox(height: 24),
// Manual Override Buttons
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _markAsKnown,
icon: const Icon(Icons.check_circle),
label: const Text('Mark as Known'),
),
Text(
'Manual Override',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _markAsNeedsPractice,
icon: const Icon(Icons.school),
label: const Text('Needs Practice'),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
setState(() {
_manualOverrides[question.id] = false;
_deck = DeckService.markQuestionAsKnown(
deck: _deck!,
questionId: question.id,
);
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Question marked as Known'),
backgroundColor: Colors.green,
),
);
},
icon: const Icon(Icons.check_circle),
label: const Text('Mark as Known'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: () {
setState(() {
_manualOverrides[question.id] = true;
_deck = DeckService.markQuestionAsNeedsPractice(
deck: _deck!,
questionId: question.id,
);
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Question marked as Needs Practice'),
),
);
},
icon: const Icon(Icons.school),
label: const Text('Needs Practice'),
),
),
],
),
],
),
],
),
),
),
],
),
],
),
),
),
),
},
pageSnapping: true,
),
),
// Submit/Next Button
Padding(
// Navigation Buttons
Container(
padding: const EdgeInsets.all(16),
child: FilledButton(
onPressed: _hasAnswer ? _submitAnswer : null,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: Row(
children: [
// Previous Button
Expanded(
child: OutlinedButton.icon(
onPressed: _currentQuestionIndex > 0 ? _goToPreviousQuestion : null,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 24),
minimumSize: const Size(0, 56),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
icon: const Icon(Icons.arrow_back, size: 20),
label: const Text(
'Previous',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
),
),
const SizedBox(width: 12),
// Next/Complete Button
Expanded(
child: FilledButton.icon(
onPressed: _isLastQuestion
? (_hasAnswer ? _submitAnswer : null)
: _goToNextQuestion,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 24),
minimumSize: const Size(0, 56),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
icon: Icon(
_isLastQuestion ? Icons.check_circle : Icons.arrow_forward,
size: 24,
color: _isLastQuestion ? Colors.green : null,
),
label: Text(
_isLastQuestion ? 'Complete' : 'Next',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
),
),
],
),
child: Text(_isLastQuestion ? 'Complete Attempt' : 'Next Question'),
),
),
],
),
),
);
}
}

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:practice_engine/practice_engine.dart';
import '../services/deck_storage.dart';
import '../routes.dart';
class DeckConfigScreen extends StatefulWidget {
const DeckConfigScreen({super.key});
@ -15,23 +17,46 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
late TextEditingController _attemptSizeController;
late TextEditingController _priorityIncreaseController;
late TextEditingController _priorityDecreaseController;
late TextEditingController _timeLimitHoursController;
late TextEditingController _timeLimitMinutesController;
late TextEditingController _timeLimitSecondsController;
late bool _immediateFeedback;
late bool _includeKnownInAttempts;
bool _timeLimitEnabled = false;
String? _lastDeckId;
int _configHashCode = 0;
@override
void initState() {
super.initState();
_deck = null;
_lastDeckId = null;
_configHashCode = 0;
// Initialize controllers with default values
_timeLimitHoursController = TextEditingController();
_timeLimitMinutesController = TextEditingController();
_timeLimitSecondsController = TextEditingController();
}
@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;
void _initializeFromDeck(Deck deck) {
// Dispose old controllers if they exist
if (_deck != null) {
_consecutiveController.dispose();
_attemptSizeController.dispose();
_priorityIncreaseController.dispose();
_priorityDecreaseController.dispose();
_timeLimitHoursController.dispose();
_timeLimitMinutesController.dispose();
_timeLimitSecondsController.dispose();
}
setState(() {
_deck = deck;
_config = deck.config;
_lastDeckId = deck.id;
_configHashCode = deck.config.hashCode;
// Create new controllers with current config values
_consecutiveController = TextEditingController(
text: _config!.requiredConsecutiveCorrect.toString(),
);
@ -45,6 +70,41 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
text: _config!.priorityDecreaseOnCorrect.toString(),
);
_immediateFeedback = _config!.immediateFeedbackEnabled;
_includeKnownInAttempts = _config!.includeKnownInAttempts;
// Initialize time limit controllers
_timeLimitEnabled = _config!.timeLimitSeconds != null;
final totalSeconds = _config!.timeLimitSeconds ?? 0;
final hours = totalSeconds ~/ 3600;
final minutes = (totalSeconds % 3600) ~/ 60;
final seconds = totalSeconds % 60;
_timeLimitHoursController = TextEditingController(
text: hours > 0 ? hours.toString() : '',
);
_timeLimitMinutesController = TextEditingController(
text: minutes > 0 ? minutes.toString() : '',
);
_timeLimitSecondsController = TextEditingController(
text: seconds > 0 ? seconds.toString() : '',
);
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Get deck from route arguments
final args = ModalRoute.of(context)?.settings.arguments;
final newDeck = args is Deck ? args : _createSampleDeck();
// Check if we need to update: first load, different deck, or config changed
final needsUpdate = _deck == null ||
_lastDeckId != newDeck.id ||
_configHashCode != newDeck.config.hashCode;
if (needsUpdate) {
_initializeFromDeck(newDeck);
}
}
@ -64,6 +124,9 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
_attemptSizeController.dispose();
_priorityIncreaseController.dispose();
_priorityDecreaseController.dispose();
_timeLimitHoursController.dispose();
_timeLimitMinutesController.dispose();
_timeLimitSecondsController.dispose();
super.dispose();
}
@ -101,16 +164,49 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
return;
}
final updatedConfig = _config!.copyWith(
// Calculate time limit in seconds
int? timeLimitSeconds;
if (_timeLimitEnabled) {
final hours = int.tryParse(_timeLimitHoursController.text) ?? 0;
final minutes = int.tryParse(_timeLimitMinutesController.text) ?? 0;
final seconds = int.tryParse(_timeLimitSecondsController.text) ?? 0;
final totalSeconds = hours * 3600 + minutes * 60 + seconds;
if (totalSeconds > 0) {
timeLimitSeconds = totalSeconds;
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Time limit must be greater than 0'),
backgroundColor: Colors.red,
),
);
return;
}
}
final updatedConfig = DeckConfig(
requiredConsecutiveCorrect: consecutive,
defaultAttemptSize: attemptSize,
priorityIncreaseOnIncorrect: priorityIncrease,
priorityDecreaseOnCorrect: priorityDecrease,
immediateFeedbackEnabled: _immediateFeedback,
includeKnownInAttempts: _includeKnownInAttempts,
timeLimitSeconds: timeLimitSeconds,
);
final updatedDeck = _deck!.copyWith(config: updatedConfig);
// Show success message
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Deck configuration saved successfully'),
backgroundColor: Colors.green,
duration: Duration(seconds: 1),
),
);
// Pop with the updated deck
Navigator.pop(context, updatedDeck);
}
@ -118,6 +214,84 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
Navigator.pop(context);
}
void _editDeck() {
if (_deck == null) return;
Navigator.pushNamed(
context,
Routes.deckEdit,
arguments: _deck,
).then((updatedDeck) {
if (updatedDeck != null && updatedDeck is Deck && mounted) {
// Reload the deck from storage to get the latest state
final deckStorage = DeckStorage();
final refreshedDeck = deckStorage.getDeck(updatedDeck.id);
if (refreshedDeck != null) {
setState(() {
_deck = refreshedDeck;
_initializeFromDeck(refreshedDeck);
});
}
}
});
}
Future<void> _resetDeck() async {
if (_deck == null) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Reset Deck'),
content: const Text(
'Are you sure you want to reset this deck? This will erase all attempt data, progress, and statistics. 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('Reset'),
),
],
),
);
if (confirmed == true && _deck != null) {
// Reset the deck: clear all progress, attempt history, and reset attempt counts
final resetDeck = DeckService.resetDeck(
deck: _deck!,
resetAttemptCounts: true,
clearAttemptHistory: true,
);
// Save the reset deck to storage
final deckStorage = DeckStorage();
deckStorage.saveDeck(resetDeck);
// Show success message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Deck has been reset successfully'),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
),
);
}
// Return the reset deck to update the parent screen
if (mounted) {
Navigator.pop(context, resetDeck);
}
}
}
@override
Widget build(BuildContext context) {
if (_deck == null || _config == null) {
@ -208,6 +382,164 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
});
},
),
const SizedBox(height: 16),
// Include Known in Attempts Toggle
SwitchListTile(
title: const Text('Include Known in Attempts'),
subtitle: const Text(
'Include questions marked as known in practice attempts',
),
value: _includeKnownInAttempts,
onChanged: (value) {
setState(() {
_includeKnownInAttempts = value;
});
},
),
const SizedBox(height: 16),
// Time Limit Section
SwitchListTile(
title: const Text('Enable Time Limit'),
subtitle: const Text(
'Set a time limit for attempts',
),
value: _timeLimitEnabled,
onChanged: (value) {
setState(() {
_timeLimitEnabled = value;
if (!value) {
_timeLimitHoursController.clear();
_timeLimitMinutesController.clear();
_timeLimitSecondsController.clear();
}
});
},
),
if (_timeLimitEnabled) ...[
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextField(
controller: _timeLimitHoursController,
decoration: const InputDecoration(
labelText: 'Hours',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _timeLimitMinutesController,
decoration: const InputDecoration(
labelText: 'Minutes',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _timeLimitSecondsController,
decoration: const InputDecoration(
labelText: 'Seconds',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
),
],
),
],
],
),
),
),
const SizedBox(height: 24),
// Edit Deck Section
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Deck Management',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
Text(
'Edit the deck name, description, and questions.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _editDeck,
icon: const Icon(Icons.edit),
label: const Text('Edit Deck'),
),
),
],
),
),
),
const SizedBox(height: 24),
// Reset Deck Section
Card(
color: Theme.of(context).colorScheme.errorContainer.withValues(alpha: 0.1),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.warning_amber_rounded,
color: Theme.of(context).colorScheme.error,
size: 20,
),
const SizedBox(width: 8),
Text(
'Danger Zone',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
Text(
'Reset this deck to erase all progress, attempt history, and statistics. This action cannot be undone.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _resetDeck,
icon: const Icon(Icons.refresh),
label: const Text('Reset Deck'),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
side: BorderSide(color: Theme.of(context).colorScheme.error),
),
),
),
],
),
),

@ -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();
}
}
}

@ -1,7 +1,8 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:practice_engine/practice_engine.dart';
import '../routes.dart';
import '../data/default_deck.dart';
import '../services/deck_storage.dart';
class DeckImportScreen extends StatefulWidget {
const DeckImportScreen({super.key});
@ -33,12 +34,27 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
priorityIncreaseOnIncorrect: configJson['priorityIncreaseOnIncorrect'] as int? ?? 5,
priorityDecreaseOnCorrect: configJson['priorityDecreaseOnCorrect'] as int? ?? 2,
immediateFeedbackEnabled: configJson['immediateFeedbackEnabled'] as bool? ?? true,
includeKnownInAttempts: configJson['includeKnownInAttempts'] as bool? ?? false,
);
// Parse questions
final questionsJson = json['questions'] as List<dynamic>? ?? [];
final questions = questionsJson.map((qJson) {
final questionMap = qJson as Map<String, dynamic>;
// Support both old format (correctAnswerIndex) and new format (correctAnswerIndices)
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: convert single index to list
final singleIndex = questionMap['correctAnswerIndex'] as int?;
if (singleIndex != null) {
correctIndices = [singleIndex];
}
}
return Question(
id: questionMap['id'] as String? ?? '',
prompt: questionMap['prompt'] as String? ?? '',
@ -46,7 +62,7 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
?.map((e) => e.toString())
.toList() ??
[],
correctAnswerIndex: questionMap['correctAnswerIndex'] as int? ?? 0,
correctAnswerIndices: correctIndices ?? [0],
consecutiveCorrect: questionMap['consecutiveCorrect'] as int? ?? 0,
isKnown: questionMap['isKnown'] as bool? ?? false,
priorityPoints: questionMap['priorityPoints'] as int? ?? 0,
@ -91,12 +107,12 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
throw FormatException('Deck must contain at least one question');
}
// Navigate to deck overview with the imported deck
Navigator.pushReplacementNamed(
context,
Routes.deckOverview,
arguments: deck,
);
// Save deck to storage
final deckStorage = DeckStorage();
deckStorage.saveDeck(deck);
// Navigate back to deck list
Navigator.pop(context);
} catch (e) {
setState(() {
_errorMessage = e.toString();
@ -105,6 +121,35 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
}
}
void _useDefaultDeck() {
final defaultDeck = DefaultDeck.deck;
final deckStorage = DeckStorage();
// Check if default deck already exists
if (deckStorage.hasDeck(defaultDeck.id)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Default deck already exists'),
backgroundColor: Colors.orange,
),
);
return;
}
// Save default deck to storage
deckStorage.saveDeck(defaultDeck);
// Navigate back to deck list
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Default deck added successfully'),
backgroundColor: Colors.green,
),
);
}
void _loadSampleDeck() {
final sampleJson = {
'id': 'sample-deck',
@ -116,13 +161,14 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
'priorityIncreaseOnIncorrect': 5,
'priorityDecreaseOnCorrect': 2,
'immediateFeedbackEnabled': true,
'includeKnownInAttempts': false,
},
'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,
'correctAnswerIndices': [i % 4],
'isKnown': i < 5,
};
}),
@ -142,6 +188,79 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Default Deck Button
Card(
color: Theme.of(context).colorScheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
Icons.quiz,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Start with Default Quiz',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'20 general knowledge questions ready to practice',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.8),
),
),
],
),
),
],
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _useDefaultDeck,
icon: const Icon(Icons.play_arrow),
label: const Text('Use Default Quiz'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
),
),
],
),
),
),
const SizedBox(height: 24),
// Divider
Row(
children: [
Expanded(child: Divider(color: Theme.of(context).colorScheme.outline)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'OR',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
),
Expanded(child: Divider(color: Theme.of(context).colorScheme.outline)),
],
),
const SizedBox(height: 24),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
@ -272,7 +391,8 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
const Text('• id: string (required)'),
const Text('• prompt: string (required)'),
const Text('• answers: array of strings (required)'),
const Text('• correctAnswerIndex: number (required)'),
const Text('• correctAnswerIndex: number (deprecated, use correctAnswerIndices)'),
const Text('• correctAnswerIndices: array of numbers (for multiple correct answers)'),
const Text('• isKnown: boolean (optional)'),
const Text('• consecutiveCorrect: number (optional)'),
const Text('• priorityPoints: number (optional)'),
@ -287,6 +407,7 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
const Text('• priorityIncreaseOnIncorrect: number'),
const Text('• priorityDecreaseOnCorrect: number'),
const Text('• immediateFeedbackEnabled: boolean'),
const Text('• includeKnownInAttempts: boolean'),
],
),
),

@ -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,
),
),
],
),
);
}
}

@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:practice_engine/practice_engine.dart';
import '../routes.dart';
import '../services/deck_storage.dart';
import '../widgets/attempts_chart.dart';
class DeckOverviewScreen extends StatefulWidget {
const DeckOverviewScreen({super.key});
@ -11,52 +13,53 @@ class DeckOverviewScreen extends StatefulWidget {
class _DeckOverviewScreenState extends State<DeckOverviewScreen> {
Deck? _deck;
final DeckStorage _deckStorage = DeckStorage();
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Get deck from route arguments or create sample
// Get deck from route arguments
final args = ModalRoute.of(context)?.settings.arguments;
if (args is Deck) {
// ALWAYS load the latest version from storage to ensure we have the most up-to-date deck
// This is critical for getting the latest incomplete attempt state
// Don't rely on route arguments - they might be stale
final storedDeck = _deckStorage.getDeck(args.id);
final deckToUse = storedDeck ?? args;
// Always update to get the latest state from storage
// This ensures we always have the most up-to-date incomplete attempt state
setState(() {
_deck = args;
});
} else if (_deck == null) {
// Only create sample if we don't have a deck yet
setState(() {
_deck = _createSampleDeck();
_deck = deckToUse;
});
}
}
void _saveDeck() {
if (_deck != null) {
_deckStorage.saveDeck(_deck!);
}
}
@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() async {
// Force reload from storage before checking for incomplete attempts to ensure we have latest state
Deck? deckToCheck = _deck;
if (_deck != null) {
final freshDeck = _deckStorage.getDeck(_deck!.id);
if (freshDeck != null) {
setState(() {
_deck = freshDeck;
});
deckToCheck = freshDeck; // Use the fresh deck for the check
}
}
void _startAttempt() {
if (_deck == null || _deck!.questions.isEmpty) {
if (deckToCheck == null || deckToCheck.questions.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Cannot start attempt: No questions in deck'),
@ -66,11 +69,170 @@ class _DeckOverviewScreenState extends State<DeckOverviewScreen> {
return;
}
Navigator.pushNamed(
context,
Routes.attempt,
arguments: _deck!,
);
// Check for incomplete attempt first - use the fresh deck we just loaded
if (deckToCheck.incompleteAttempt != null) {
final incomplete = deckToCheck.incompleteAttempt!;
final progress = incomplete.currentQuestionIndex + 1;
final total = incomplete.questionIds.length;
final action = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Incomplete Attempt Found'),
content: Text(
'You have an incomplete attempt (Question $progress of $total).\n\n'
'Would you like to continue where you left off, or start a new attempt?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, 'ignore'),
child: const Text('Start Fresh'),
),
FilledButton(
onPressed: () => Navigator.pop(context, 'continue'),
child: const Text('Continue'),
),
],
),
);
if (action == 'continue') {
// Continue the incomplete attempt
if (!mounted) return;
Navigator.pushNamed(
context,
Routes.attempt,
arguments: {
'deck': deckToCheck,
'resumeAttempt': true,
},
).then((_) {
// Refresh deck state when returning from attempt
_refreshDeck();
});
return;
} else if (action == 'ignore') {
// Clear incomplete attempt and start fresh
// Clear incomplete attempt - create a fresh copy without it
final updatedDeck = deckToCheck.copyWith(clearIncompleteAttempt: true);
// Save to storage multiple times to ensure it's persisted
_deckStorage.saveDeck(updatedDeck);
_deckStorage.saveDeck(updatedDeck); // Save twice to be sure
// Update local state immediately with cleared deck
setState(() {
_deck = updatedDeck;
});
// Force reload from storage multiple times to verify it's cleared
Deck? finalClearedDeck = updatedDeck;
for (int i = 0; i < 3; i++) {
final verifiedDeck = _deckStorage.getDeck(updatedDeck.id);
if (verifiedDeck != null) {
// Ensure incomplete attempt is null even if storage had it
if (verifiedDeck.incompleteAttempt != null) {
final clearedDeck = verifiedDeck.copyWith(clearIncompleteAttempt: true);
_deckStorage.saveDeck(clearedDeck);
setState(() {
_deck = clearedDeck;
});
finalClearedDeck = clearedDeck;
} else {
// Already cleared, update state
setState(() {
_deck = verifiedDeck;
});
finalClearedDeck = verifiedDeck;
break; // Exit loop if already cleared
}
}
}
// CRITICAL: Update deckToCheck to use the cleared deck for the rest of the flow
deckToCheck = finalClearedDeck ?? updatedDeck;
// Continue to normal flow below - the deck should now have no incomplete attempt
} else {
// User cancelled
return;
}
}
// Check if all questions are known and includeKnownInAttempts is disabled
final allKnown = deckToCheck.knownCount == deckToCheck.numberOfQuestions && deckToCheck.numberOfQuestions > 0;
final includeKnownDisabled = !deckToCheck.config.includeKnownInAttempts;
if (allKnown && includeKnownDisabled) {
// Show confirmation dialog
final shouldIncludeKnown = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('All Questions Known'),
content: const Text(
'All questions are known, attempt with known questions?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('No'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Yes'),
),
],
),
);
if (shouldIncludeKnown == null || !shouldIncludeKnown) {
// User cancelled or said No
return;
}
// Pass the deck with a flag to include known questions
// Ensure we're using a deck without incomplete attempt
if (!mounted) return;
final deckToUse = deckToCheck.copyWith(clearIncompleteAttempt: true);
Navigator.pushNamed(
context,
Routes.attempt,
arguments: {
'deck': deckToUse,
'includeKnown': true,
},
).then((_) {
// Refresh deck state when returning from attempt
_refreshDeck();
});
} else {
// Normal flow - ensure we're using a deck without incomplete attempt
if (!mounted) return;
// Always use a fresh deck copy without incomplete attempt to prevent stale state
// Also ensure storage has the cleared state
final deckToUse = deckToCheck.copyWith(clearIncompleteAttempt: true);
// Ensure storage also has the cleared state
_deckStorage.saveDeck(deckToUse);
Navigator.pushNamed(
context,
Routes.attempt,
arguments: deckToUse,
).then((_) {
// Refresh deck state when returning from attempt
_refreshDeck();
});
}
}
void _refreshDeck() {
if (_deck != null) {
// Reload the deck from storage to get the latest state (including incomplete attempts)
final refreshedDeck = _deckStorage.getDeck(_deck!.id);
if (refreshedDeck != null && mounted) {
setState(() {
_deck = refreshedDeck;
});
}
}
}
void _openConfig() {
@ -79,53 +241,49 @@ class _DeckOverviewScreenState extends State<DeckOverviewScreen> {
Navigator.pushNamed(context, Routes.deckConfig, arguments: _deck)
.then((updatedDeck) {
if (updatedDeck != null && updatedDeck is Deck) {
final wasReset = updatedDeck.attemptHistory.isEmpty &&
updatedDeck.currentAttemptIndex == 0 &&
_deck != null &&
_deck!.attemptHistory.isNotEmpty;
setState(() {
_deck = updatedDeck;
});
_saveDeck(); // Save to storage
// Show confirmation message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(wasReset
? 'Deck has been reset successfully'
: 'Deck settings have been updated'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
}
}
});
}
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);
});
String _formatTime(int milliseconds) {
final seconds = milliseconds ~/ 1000;
final minutes = seconds ~/ 60;
final hours = minutes ~/ 60;
final remainingMinutes = minutes % 60;
final remainingSeconds = seconds % 60;
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Deck progress has been reset'),
backgroundColor: Colors.green,
),
);
}
if (hours > 0) {
return '${hours}h ${remainingMinutes}m';
} else if (minutes > 0) {
return '${minutes}m ${remainingSeconds}s';
} else {
return '${remainingSeconds}s';
}
}
@override
Widget build(BuildContext context) {
if (_deck == null) {
@ -137,111 +295,188 @@ class _DeckOverviewScreenState extends State<DeckOverviewScreen> {
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),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Deck Description
Card(
child: Padding(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Description',
style: Theme.of(context).textTheme.titleMedium,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
const SizedBox(height: 4),
Text(
_deck!.description,
style: Theme.of(context).textTheme.bodyMedium,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
const SizedBox(height: 24),
const SizedBox(height: 12),
// Practice Progress
Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Text(
'Practice Progress',
style: Theme.of(context).textTheme.titleLarge,
SizedBox(
width: 80,
height: 80,
child: CircularProgressIndicator(
value: _deck!.practicePercentage / 100,
strokeWidth: 6,
),
),
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(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_deck!.practicePercentage.toStringAsFixed(1)}%',
style: Theme.of(context).textTheme.headlineSmall?.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: 4),
Text(
'${_deck!.knownCount} of ${_deck!.numberOfQuestions} questions known',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
),
),
const SizedBox(height: 24),
const SizedBox(height: 12),
// Statistics
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_StatItem(
label: 'Total',
value: '${_deck!.numberOfQuestions}',
icon: Icons.quiz,
),
_StatItem(
label: 'Known',
value: '${_deck!.knownCount}',
icon: Icons.check_circle,
),
_StatItem(
label: 'Practice',
value: '${_deck!.numberOfQuestions - _deck!.knownCount}',
icon: Icons.school,
),
],
),
),
),
const SizedBox(height: 12),
// Attempts History
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,
Icon(
Icons.history,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
_StatItem(
label: 'Needs Practice',
value: '${_deck!.numberOfQuestions - _deck!.knownCount}',
icon: Icons.school,
const SizedBox(width: 8),
Text(
'Attempts History',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
if (_deck!.attemptHistory.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Column(
children: [
Icon(
Icons.quiz_outlined,
size: 48,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
),
const SizedBox(height: 8),
Text(
'No attempts yet',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
),
)
else ...[
// Summary Statistics
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_HistoryStatItem(
label: 'Attempts',
value: '${_deck!.attemptCount}',
icon: Icons.quiz,
),
_HistoryStatItem(
label: 'Avg. Score',
value: '${_deck!.averagePercentageCorrect.toStringAsFixed(1)}%',
icon: Icons.trending_up,
),
_HistoryStatItem(
label: 'Total Time',
value: _formatTime(_deck!.totalTimeSpent),
icon: Icons.timer,
),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
Text(
'Progress Chart',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
// Progress chart
AttemptsChart(
attempts: _deck!.attemptHistory,
maxDisplayItems: 15,
),
],
],
),
),
),
const SizedBox(height: 24),
const SizedBox(height: 12),
// Action Buttons
FilledButton.icon(
@ -249,14 +484,17 @@ class _DeckOverviewScreenState extends State<DeckOverviewScreen> {
icon: const Icon(Icons.play_arrow),
label: const Text('Start Attempt'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
const SizedBox(height: 12),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _openConfig,
icon: const Icon(Icons.settings),
label: const Text('Configure Deck'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
],
),
@ -279,12 +517,13 @@ class _StatItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 32),
const SizedBox(height: 8),
Icon(icon, size: 24),
const SizedBox(height: 4),
Text(
value,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
@ -297,3 +536,37 @@ class _StatItem extends StatelessWidget {
}
}
class _HistoryStatItem extends StatelessWidget {
final String label;
final String value;
final IconData icon;
const _HistoryStatItem({
required this.label,
required this.value,
required this.icon,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 4),
Text(
value,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
}

@ -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;
}
}

@ -5,6 +5,7 @@ class AnswerOption extends StatelessWidget {
final bool isSelected;
final VoidCallback onTap;
final bool? isCorrect;
final bool isMultipleChoice;
const AnswerOption({
super.key,
@ -12,6 +13,7 @@ class AnswerOption extends StatelessWidget {
required this.isSelected,
required this.onTap,
this.isCorrect,
this.isMultipleChoice = false,
});
@override
@ -63,12 +65,12 @@ class AnswerOption extends StatelessWidget {
),
if (isSelected && icon == null)
Icon(
Icons.radio_button_checked,
isMultipleChoice ? Icons.check_box : Icons.radio_button_checked,
color: Theme.of(context).colorScheme.primary,
)
else if (!isSelected)
Icon(
Icons.radio_button_unchecked,
isMultipleChoice ? Icons.check_box_outline_blank : Icons.radio_button_unchecked,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
),
],

@ -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…
Cancel
Save

Powered by TurnKey Linux.