This commit is contained in:
gitea
2025-12-23 18:13:12 +01:00
parent 5644bfde61
commit 5362b21f08
15 changed files with 4273 additions and 297 deletions
+19 -16
View File
@@ -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),
),
+650 -126
View File
@@ -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 _selectAnswer(int index) {
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 _onPageChanged(int index) {
setState(() {
_selectedAnswerIndex = index;
_currentQuestionIndex = index;
_loadQuestionState();
});
}
if (_deck != null && _deck!.config.immediateFeedbackEnabled) {
// Show feedback immediately
void _loadQuestionState() {
// Load saved answer for current question
final currentQuestionId = _currentQuestion.id;
final savedAnswer = _answers[currentQuestionId];
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;
// 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,
);
// 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 Known'),
backgroundColor: Colors.green,
content: Text('Attempt saved. You can continue later.'),
backgroundColor: Colors.blue,
duration: Duration(seconds: 2),
),
);
}
void _markAsNeedsPractice() {
if (_deck == null) return;
setState(() {
_manualOverrides[_currentQuestion.id] = true;
_deck = DeckService.markQuestionAsNeedsPractice(
deck: _deck!,
questionId: _currentQuestion.id,
);
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Question marked as Needs Practice'),
),
);
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,76 +493,289 @@ 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,
),
),
// Navigation Buttons
Container(
padding: const EdgeInsets.all(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,
),
),
),
),
@@ -248,20 +783,9 @@ class _AttemptScreenState extends State<AttemptScreen> {
),
),
),
// Submit/Next Button
Padding(
padding: const EdgeInsets.all(16),
child: FilledButton(
onPressed: _hasAnswer ? _submitAnswer : null,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: Text(_isLastQuestion ? 'Complete Attempt' : 'Next Question'),
),
),
],
),
),
);
}
}
+341 -9
View File
@@ -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),
),
),
),
],
),
),
+215
View File
@@ -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),
),
),
],
),
),
),
),
],
),
),
);
}
}
+455
View File
@@ -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();
}
}
}
+131 -10
View File
@@ -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'),
],
),
),
+423
View File
@@ -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,
),
),
],
),
);
}
}
+404 -131
View File
@@ -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() {
if (_deck == null || _deck!.questions.isEmpty) {
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
}
}
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);
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Deck progress has been reset'),
backgroundColor: Colors.green,
),
);
}
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 (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,
Icon(
Icons.history,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
_StatItem(
label: 'Known',
value: '${_deck!.knownCount}',
icon: Icons.check_circle,
),
_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,
),
],
);
}
}