ok
This commit is contained in:
@@ -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
@@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user