gitea 2 months ago
parent 5644bfde61
commit 5362b21f08

File diff suppressed because it is too large Load Diff

@ -0,0 +1,146 @@
import 'package:practice_engine/practice_engine.dart';
/// Default deck with 20 general knowledge questions for practice.
class DefaultDeck {
static Deck get deck {
const config = DeckConfig(
requiredConsecutiveCorrect: 3,
defaultAttemptSize: 10,
priorityIncreaseOnIncorrect: 5,
priorityDecreaseOnCorrect: 2,
immediateFeedbackEnabled: true,
);
final questions = [
Question(
id: 'gk_1',
prompt: 'What is the capital city of Australia?',
answers: ['Sydney', 'Melbourne', 'Canberra', 'Perth'],
correctAnswerIndices: [2],
),
Question(
id: 'gk_2',
prompt: 'Which planet is known as the Red Planet?',
answers: ['Venus', 'Mars', 'Jupiter', 'Saturn'],
correctAnswerIndices: [1],
),
Question(
id: 'gk_3',
prompt: 'What is the largest ocean on Earth?',
answers: ['Atlantic Ocean', 'Indian Ocean', 'Arctic Ocean', 'Pacific Ocean'],
correctAnswerIndices: [3],
),
Question(
id: 'gk_4',
prompt: 'Who wrote the novel "1984"?',
answers: ['George Orwell', 'Aldous Huxley', 'Ray Bradbury', 'J.D. Salinger'],
correctAnswerIndices: [0],
),
Question(
id: 'gk_5',
prompt: 'What is the chemical symbol for gold?',
answers: ['Go', 'Gd', 'Au', 'Ag'],
correctAnswerIndices: [2],
),
Question(
id: 'gk_6',
prompt: 'In which year did World War II end?',
answers: ['1943', '1944', '1945', '1946'],
correctAnswerIndices: [2],
),
Question(
id: 'gk_7',
prompt: 'What is the smallest prime number?',
answers: ['0', '1', '2', '3'],
correctAnswerIndices: [2],
),
Question(
id: 'gk_8',
prompt: 'Which gas makes up approximately 78% of Earth\'s atmosphere?',
answers: ['Oxygen', 'Carbon Dioxide', 'Nitrogen', 'Argon'],
correctAnswerIndices: [2],
),
Question(
id: 'gk_9',
prompt: 'What is the longest river in the world?',
answers: ['Amazon River', 'Nile River', 'Yangtze River', 'Mississippi River'],
correctAnswerIndices: [1],
),
Question(
id: 'gk_10',
prompt: 'Who painted the Mona Lisa?',
answers: ['Vincent van Gogh', 'Pablo Picasso', 'Leonardo da Vinci', 'Michelangelo'],
correctAnswerIndices: [2],
),
Question(
id: 'gk_11',
prompt: 'What is the speed of light in a vacuum (approximately)?',
answers: ['300,000 km/s', '150,000 km/s', '450,000 km/s', '600,000 km/s'],
correctAnswerIndices: [0],
),
Question(
id: 'gk_12',
prompt: 'Which country is home to the kangaroo?',
answers: ['New Zealand', 'Australia', 'South Africa', 'Brazil'],
correctAnswerIndices: [1],
),
Question(
id: 'gk_13',
prompt: 'What is the hardest natural substance on Earth?',
answers: ['Gold', 'Diamond', 'Platinum', 'Titanium'],
correctAnswerIndices: [1],
),
Question(
id: 'gk_14',
prompt: 'How many continents are there on Earth?',
answers: ['5', '6', '7', '8'],
correctAnswerIndices: [2],
),
Question(
id: 'gk_15',
prompt: 'What is the largest mammal in the world?',
answers: ['African Elephant', 'Blue Whale', 'Giraffe', 'Polar Bear'],
correctAnswerIndices: [1],
),
Question(
id: 'gk_16',
prompt: 'In which city is the Eiffel Tower located?',
answers: ['London', 'Berlin', 'Paris', 'Rome'],
correctAnswerIndices: [2],
),
Question(
id: 'gk_17',
prompt: 'What is the square root of 64?',
answers: ['6', '7', '8', '9'],
correctAnswerIndices: [2],
),
Question(
id: 'gk_18',
prompt: 'Which element has the atomic number 1?',
answers: ['Helium', 'Hydrogen', 'Lithium', 'Carbon'],
correctAnswerIndices: [1],
),
Question(
id: 'gk_19',
prompt: 'What is the largest desert in the world?',
answers: ['Gobi Desert', 'Sahara Desert', 'Antarctic Desert', 'Arabian Desert'],
correctAnswerIndices: [2],
),
Question(
id: 'gk_20',
prompt: 'Who invented the telephone?',
answers: ['Thomas Edison', 'Alexander Graham Bell', 'Nikola Tesla', 'Guglielmo Marconi'],
correctAnswerIndices: [1],
),
];
return Deck(
id: 'default-general-knowledge',
title: 'General Knowledge Quiz',
description: 'A collection of 20 general knowledge questions covering science, geography, history, and more. Perfect for testing your knowledge!',
questions: questions,
config: config,
);
}
}

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

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

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

@ -1,8 +1,65 @@
import 'package:flutter/material.dart';
import 'dart:math' as math;
import 'dart:async';
import 'dart:ui';
import 'package:practice_engine/practice_engine.dart';
import '../routes.dart';
import '../widgets/question_card.dart';
import '../widgets/answer_option.dart';
import '../services/deck_storage.dart';
/// Custom 3D cube page transformer
class CubePageTransitionsBuilder extends PageTransitionsBuilder {
const CubePageTransitionsBuilder();
@override
Widget buildTransitions<T extends Object?>(
PageRoute<T> route,
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return _CubeTransition(
animation: animation,
child: child,
);
}
}
class _CubeTransition extends StatelessWidget {
final Animation<double> animation;
final Widget child;
const _CubeTransition({
required this.animation,
required this.child,
});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
final value = animation.value;
final angle = (1.0 - value) * math.pi / 2;
final opacity = value < 0.5 ? 0.0 : (value - 0.5) * 2;
return Transform(
alignment: Alignment.centerLeft,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateY(angle),
child: Opacity(
opacity: opacity.clamp(0.0, 1.0),
child: child,
),
);
},
child: child,
);
}
}
class AttemptScreen extends StatefulWidget {
const AttemptScreen({super.key});
@ -16,14 +73,21 @@ class _AttemptScreenState extends State<AttemptScreen> {
Attempt? _attempt;
AttemptService? _attemptService;
int _currentQuestionIndex = 0;
int? _selectedAnswerIndex;
final Map<String, int> _answers = {};
final Map<String, bool> _manualOverrides = {};
int? _selectedAnswerIndex; // For single answer questions (backward compatibility)
Set<int> _selectedAnswerIndices = {}; // For multiple answer questions
Map<String, dynamic> _answers = {}; // Can store int or List<int>
Map<String, bool> _manualOverrides = {};
final DeckStorage _deckStorage = DeckStorage();
late PageController _pageController;
Timer? _timer;
int _remainingSeconds = 0;
int _startTime = 0;
@override
void initState() {
super.initState();
_attemptService = AttemptService();
_pageController = PageController();
}
@override
@ -32,9 +96,98 @@ 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() {
@ -44,7 +197,7 @@ class _AttemptScreenState extends State<AttemptScreen> {
id: 'q$i',
prompt: 'Sample Question $i?',
answers: ['A', 'B', 'C', 'D'],
correctAnswerIndex: i % 4,
correctAnswerIndices: [i % 4],
);
});
return Deck(
@ -58,84 +211,180 @@ class _AttemptScreenState extends State<AttemptScreen> {
Question get _currentQuestion => _attempt!.questions[_currentQuestionIndex];
bool get _isLastQuestion => _currentQuestionIndex == _attempt!.questions.length - 1;
bool get _hasAnswer => _selectedAnswerIndex != null;
bool get _hasMultipleCorrect => _currentQuestion.hasMultipleCorrectAnswers;
bool get _hasAnswer {
if (_hasMultipleCorrect) {
return _selectedAnswerIndices.isNotEmpty;
} else {
return _selectedAnswerIndex != null;
}
}
/// Returns true if at least one question has been answered (progress has been made)
bool get _hasAnyProgress => _answers.isNotEmpty || _manualOverrides.isNotEmpty;
void _goToPreviousQuestion() {
if (_currentQuestionIndex > 0) {
// Save current answer if any
if (_hasAnswer) {
if (_hasMultipleCorrect) {
_answers[_currentQuestion.id] = _selectedAnswerIndices.toList()..sort();
} else {
_answers[_currentQuestion.id] = _selectedAnswerIndex!;
}
}
void _selectAnswer(int index) {
_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();
});
}
void _loadQuestionState() {
// Load saved answer for current question
final currentQuestionId = _currentQuestion.id;
final savedAnswer = _answers[currentQuestionId];
if (_deck != null && _deck!.config.immediateFeedbackEnabled) {
// Show feedback immediately
if (savedAnswer != null) {
if (savedAnswer is int) {
_selectedAnswerIndex = savedAnswer;
_selectedAnswerIndices.clear();
} else if (savedAnswer is List<int>) {
_selectedAnswerIndices = savedAnswer.toSet();
_selectedAnswerIndex = null;
}
} else {
_selectedAnswerIndex = null;
_selectedAnswerIndices.clear();
}
}
void _submitAnswer() {
if (_selectedAnswerIndex == null) return;
if (!_hasAnswer) return;
// Store answer(s) based on question type
if (_hasMultipleCorrect) {
_answers[_currentQuestion.id] = _selectedAnswerIndices.toList()..sort();
} else {
_answers[_currentQuestion.id] = _selectedAnswerIndex!;
}
if (_isLastQuestion) {
_completeAttempt();
} else {
setState(() {
_currentQuestionIndex++;
_selectedAnswerIndex = null;
});
_goToNextQuestion();
}
}
void _markAsKnown() {
if (_deck == null) return;
setState(() {
_manualOverrides[_currentQuestion.id] = false; // Not needs practice
_deck = DeckService.markQuestionAsKnown(
deck: _deck!,
questionId: _currentQuestion.id,
);
});
void _saveForLater() {
if (_deck == null || _attempt == null) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Question marked as Known'),
backgroundColor: Colors.green,
),
);
// Save current answer if selected
if (_hasAnswer) {
if (_hasMultipleCorrect) {
_answers[_currentQuestion.id] = _selectedAnswerIndices.toList()..sort();
} else {
_answers[_currentQuestion.id] = _selectedAnswerIndex!;
}
}
void _markAsNeedsPractice() {
if (_deck == null) return;
setState(() {
_manualOverrides[_currentQuestion.id] = true;
_deck = DeckService.markQuestionAsNeedsPractice(
deck: _deck!,
questionId: _currentQuestion.id,
// 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 Needs Practice'),
content: Text('Attempt saved. You can continue later.'),
backgroundColor: Colors.blue,
duration: Duration(seconds: 2),
),
);
Navigator.pop(context);
}
void _completeAttempt() {
if (_deck == null || _attempt == null || _attemptService == null) return;
final endTime = DateTime.now().millisecondsSinceEpoch;
final result = _attemptService!.processAttempt(
deck: _deck!,
attempt: _attempt!,
answers: _answers,
manualOverrides: _manualOverrides,
endTime: DateTime.now().millisecondsSinceEpoch,
endTime: endTime,
);
// Add attempt to history
final historyEntry = AttemptHistoryEntry.fromAttemptResult(
result: result.result,
timestamp: endTime,
);
final updatedDeckWithHistory = result.updatedDeck.copyWith(
attemptHistory: [...result.updatedDeck.attemptHistory, historyEntry],
clearIncompleteAttempt: true, // Clear incomplete attempt when completed
);
// Save the updated deck to storage
_deckStorage.saveDeck(updatedDeckWithHistory);
Navigator.pushReplacementNamed(
context,
Routes.attemptResult,
arguments: {
'deck': result.updatedDeck,
'deck': updatedDeckWithHistory,
'result': result.result,
'attempt': _attempt,
},
@ -150,12 +399,85 @@ class _AttemptScreenState extends State<AttemptScreen> {
);
}
return Scaffold(
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'),
),
],
),
);
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,
@ -171,38 +493,149 @@ class _AttemptScreenState extends State<AttemptScreen> {
'Question ${_currentQuestionIndex + 1} of ${_attempt!.questions.length}',
style: Theme.of(context).textTheme.bodyMedium,
),
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)
Chip(
label: const Text('Known'),
avatar: const Icon(Icons.check_circle, size: 18),
const Chip(
label: Text('Known'),
avatar: Icon(Icons.check_circle, size: 18),
),
],
),
],
),
),
// Question Card
// Question Card with PageView for 3D transitions
Expanded(
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: _currentQuestion),
QuestionCard(question: question),
const SizedBox(height: 24),
// Answer Options
...List.generate(
_currentQuestion.answers.length,
(index) => Padding(
question.answers.length,
(answerIndex) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: AnswerOption(
text: _currentQuestion.answers[index],
isSelected: _selectedAnswerIndex == index,
onTap: () => _selectAnswer(index),
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 &&
_selectedAnswerIndex == index
? index == _currentQuestion.correctAnswerIndex
(isMultipleCorrect
? selectedIndices.contains(answerIndex)
: selectedIndex == answerIndex)
? question.isCorrectAnswer(answerIndex)
: null,
isMultipleChoice: isMultipleCorrect,
),
),
),
@ -225,7 +658,21 @@ class _AttemptScreenState extends State<AttemptScreen> {
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _markAsKnown,
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'),
),
@ -233,7 +680,20 @@ class _AttemptScreenState extends State<AttemptScreen> {
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _markAsNeedsPractice,
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'),
),
@ -247,21 +707,85 @@ class _AttemptScreenState extends State<AttemptScreen> {
],
),
),
},
pageSnapping: true,
),
),
// Submit/Next Button
Padding(
// Navigation Buttons
Container(
padding: const EdgeInsets.all(16),
child: FilledButton(
onPressed: _hasAnswer ? _submitAnswer : null,
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: 16),
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 24),
minimumSize: const Size(0, 56),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
icon: Icon(
_isLastQuestion ? Icons.check_circle : Icons.arrow_forward,
size: 24,
color: _isLastQuestion ? Colors.green : null,
),
label: Text(
_isLastQuestion ? 'Complete' : 'Next',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
),
),
],
),
child: Text(_isLastQuestion ? 'Complete Attempt' : 'Next Question'),
),
),
],
),
),
);
}
}

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

@ -0,0 +1,215 @@
import 'package:flutter/material.dart';
import 'package:practice_engine/practice_engine.dart';
import '../services/deck_storage.dart';
import 'deck_edit_screen.dart';
class DeckCreateScreen extends StatefulWidget {
const DeckCreateScreen({super.key});
@override
State<DeckCreateScreen> createState() => _DeckCreateScreenState();
}
class _DeckCreateScreenState extends State<DeckCreateScreen> {
late TextEditingController _titleController;
late TextEditingController _descriptionController;
final List<QuestionEditor> _questionEditors = [];
final DeckStorage _deckStorage = DeckStorage();
@override
void initState() {
super.initState();
_titleController = TextEditingController();
_descriptionController = TextEditingController();
// Start with one empty question
_questionEditors.add(QuestionEditor.empty());
}
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
for (final editor in _questionEditors) {
editor.dispose();
}
super.dispose();
}
void _addQuestion() {
setState(() {
_questionEditors.add(QuestionEditor.empty());
});
}
void _removeQuestion(int index) {
setState(() {
_questionEditors.removeAt(index);
});
}
void _save() {
final title = _titleController.text.trim();
if (title.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Deck title cannot be empty'),
backgroundColor: Colors.red,
),
);
return;
}
// Validate all questions
final questions = <Question>[];
for (int i = 0; i < _questionEditors.length; i++) {
final editor = _questionEditors[i];
if (!editor.isValid) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Question ${i + 1} is invalid. Please fill in all fields.'),
backgroundColor: Colors.red,
),
);
return;
}
questions.add(editor.toQuestion());
}
if (questions.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Deck must have at least one question'),
backgroundColor: Colors.red,
),
);
return;
}
// Create new deck
final newDeck = Deck(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: title,
description: _descriptionController.text.trim(),
questions: questions,
config: const DeckConfig(),
currentAttemptIndex: 0,
attemptHistory: [],
);
_deckStorage.saveDeck(newDeck);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Deck created successfully'),
backgroundColor: Colors.green,
duration: Duration(seconds: 1),
),
);
Navigator.pop(context, newDeck);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Create New Deck'),
actions: [
IconButton(
icon: const Icon(Icons.save),
onPressed: _save,
tooltip: 'Save Deck',
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Deck Title
TextField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Deck Title',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
// Deck Description
TextField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description (optional)',
border: OutlineInputBorder(),
),
maxLines: 3,
),
const SizedBox(height: 24),
// Questions Section
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Questions',
style: Theme.of(context).textTheme.titleLarge,
),
FilledButton.icon(
onPressed: _addQuestion,
icon: const Icon(Icons.add),
label: const Text('Add Question'),
),
],
),
const SizedBox(height: 16),
// Questions List
...List.generate(_questionEditors.length, (index) {
return QuestionEditorCard(
key: ValueKey('question_$index'),
editor: _questionEditors[index],
questionNumber: index + 1,
onDelete: () => _removeQuestion(index),
onChanged: () => setState(() {}),
);
}),
if (_questionEditors.isEmpty)
Card(
child: Padding(
padding: const EdgeInsets.all(32),
child: Center(
child: Column(
children: [
Icon(
Icons.quiz_outlined,
size: 48,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
),
const SizedBox(height: 16),
Text(
'No questions yet',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 8),
Text(
'Tap "Add Question" to get started',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
),
),
),
],
),
),
);
}
}

@ -0,0 +1,455 @@
import 'package:flutter/material.dart';
import 'package:practice_engine/practice_engine.dart';
import '../services/deck_storage.dart';
class DeckEditScreen extends StatefulWidget {
const DeckEditScreen({super.key});
@override
State<DeckEditScreen> createState() => _DeckEditScreenState();
}
class _DeckEditScreenState extends State<DeckEditScreen> {
Deck? _deck;
late TextEditingController _titleController;
late TextEditingController _descriptionController;
final List<QuestionEditor> _questionEditors = [];
final DeckStorage _deckStorage = DeckStorage();
@override
void initState() {
super.initState();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_deck == null) {
final args = ModalRoute.of(context)?.settings.arguments;
if (args is Deck) {
_initializeFromDeck(args);
}
}
}
void _initializeFromDeck(Deck deck) {
setState(() {
_deck = deck;
_titleController = TextEditingController(text: deck.title);
_descriptionController = TextEditingController(text: deck.description);
_questionEditors.clear();
for (final question in deck.questions) {
_questionEditors.add(QuestionEditor.fromQuestion(question));
}
});
}
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
for (final editor in _questionEditors) {
editor.dispose();
}
super.dispose();
}
void _addQuestion() {
setState(() {
_questionEditors.add(QuestionEditor.empty());
});
}
void _removeQuestion(int index) {
setState(() {
_questionEditors.removeAt(index);
});
}
void _save() {
if (_deck == null) return;
final title = _titleController.text.trim();
if (title.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Deck title cannot be empty'),
backgroundColor: Colors.red,
),
);
return;
}
// Validate all questions
final questions = <Question>[];
for (int i = 0; i < _questionEditors.length; i++) {
final editor = _questionEditors[i];
if (!editor.isValid) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Question ${i + 1} is invalid. Please fill in all fields.'),
backgroundColor: Colors.red,
),
);
return;
}
questions.add(editor.toQuestion());
}
if (questions.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Deck must have at least one question'),
backgroundColor: Colors.red,
),
);
return;
}
// Preserve attempt history and other deck data when editing
final updatedDeck = _deck!.copyWith(
title: title,
description: _descriptionController.text.trim(),
questions: questions,
// Preserve attempt history, config, and current attempt index
attemptHistory: _deck!.attemptHistory,
config: _deck!.config,
currentAttemptIndex: _deck!.currentAttemptIndex,
);
_deckStorage.saveDeck(updatedDeck);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Deck saved successfully'),
backgroundColor: Colors.green,
duration: Duration(seconds: 1),
),
);
Navigator.pop(context, updatedDeck);
}
@override
Widget build(BuildContext context) {
if (_deck == null) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Edit Deck'),
actions: [
IconButton(
icon: const Icon(Icons.save),
onPressed: _save,
tooltip: 'Save Deck',
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Deck Title
TextField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Deck Title',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
// Deck Description
TextField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description (optional)',
border: OutlineInputBorder(),
),
maxLines: 3,
),
const SizedBox(height: 24),
// Questions Section
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Questions',
style: Theme.of(context).textTheme.titleLarge,
),
FilledButton.icon(
onPressed: _addQuestion,
icon: const Icon(Icons.add),
label: const Text('Add Question'),
),
],
),
const SizedBox(height: 16),
// Questions List
...List.generate(_questionEditors.length, (index) {
return QuestionEditorCard(
key: ValueKey('question_$index'),
editor: _questionEditors[index],
questionNumber: index + 1,
onDelete: () => _removeQuestion(index),
onChanged: () => setState(() {}),
);
}),
if (_questionEditors.isEmpty)
Card(
child: Padding(
padding: const EdgeInsets.all(32),
child: Center(
child: Column(
children: [
Icon(
Icons.quiz_outlined,
size: 48,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
),
const SizedBox(height: 16),
Text(
'No questions yet',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 8),
Text(
'Tap "Add Question" to get started',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
),
),
),
],
),
),
);
}
}
class QuestionEditorCard extends StatefulWidget {
final QuestionEditor editor;
final int questionNumber;
final VoidCallback onDelete;
final VoidCallback onChanged;
const QuestionEditorCard({
super.key,
required this.editor,
required this.questionNumber,
required this.onDelete,
required this.onChanged,
});
@override
State<QuestionEditorCard> createState() => _QuestionEditorCardState();
}
class _QuestionEditorCardState extends State<QuestionEditorCard> {
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Question ${widget.questionNumber}',
style: Theme.of(context).textTheme.titleMedium,
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: widget.onDelete,
tooltip: 'Delete Question',
),
],
),
const SizedBox(height: 12),
// Question Prompt
TextField(
controller: widget.editor.promptController,
decoration: const InputDecoration(
labelText: 'Question',
border: OutlineInputBorder(),
),
maxLines: 2,
),
const SizedBox(height: 16),
// Answers
Text(
'Answers',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
...List.generate(widget.editor.answerControllers.length, (index) {
final isCorrect = widget.editor.correctAnswerIndices.contains(index);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Checkbox(
value: isCorrect,
onChanged: (value) {
setState(() {
if (value == true) {
widget.editor.correctAnswerIndices.add(index);
} else {
widget.editor.correctAnswerIndices.remove(index);
// Ensure at least one answer is marked as correct
if (widget.editor.correctAnswerIndices.isEmpty &&
widget.editor.answerControllers.length > 0) {
widget.editor.correctAnswerIndices.add(0);
}
}
});
widget.onChanged();
},
),
Expanded(
child: TextField(
controller: widget.editor.answerControllers[index],
decoration: InputDecoration(
labelText: 'Answer ${index + 1}',
border: const OutlineInputBorder(),
suffixIcon: isCorrect
? const Icon(Icons.check_circle, color: Colors.green)
: null,
),
),
),
],
),
);
}),
// Add/Remove Answer buttons
Row(
children: [
TextButton.icon(
onPressed: () {
setState(() {
widget.editor.answerControllers.add(TextEditingController());
});
widget.onChanged();
},
icon: const Icon(Icons.add),
label: const Text('Add Answer'),
),
if (widget.editor.answerControllers.length > 2)
TextButton.icon(
onPressed: () {
setState(() {
final lastIndex = widget.editor.answerControllers.length - 1;
// Remove the index from correct answers if it was marked
widget.editor.correctAnswerIndices.remove(lastIndex);
// Adjust indices if needed
widget.editor.correctAnswerIndices = widget.editor.correctAnswerIndices
.where((idx) => idx < lastIndex)
.toSet();
// Ensure at least one answer is marked as correct
if (widget.editor.correctAnswerIndices.isEmpty &&
widget.editor.answerControllers.length > 1) {
widget.editor.correctAnswerIndices.add(0);
}
widget.editor.answerControllers.removeLast().dispose();
});
widget.onChanged();
},
icon: const Icon(Icons.remove),
label: const Text('Remove Answer'),
),
],
),
],
),
),
);
}
}
class QuestionEditor {
final TextEditingController promptController;
final List<TextEditingController> answerControllers;
Set<int> correctAnswerIndices;
final String? originalId;
QuestionEditor({
required this.promptController,
required this.answerControllers,
Set<int>? correctAnswerIndices,
this.originalId,
}) : correctAnswerIndices = correctAnswerIndices ?? {0};
factory QuestionEditor.fromQuestion(Question question) {
return QuestionEditor(
promptController: TextEditingController(text: question.prompt),
answerControllers: question.answers
.map((answer) => TextEditingController(text: answer))
.toList(),
correctAnswerIndices: question.correctIndices.toSet(),
originalId: question.id,
);
}
factory QuestionEditor.empty() {
return QuestionEditor(
promptController: TextEditingController(),
answerControllers: [
TextEditingController(),
TextEditingController(),
],
correctAnswerIndices: {0},
);
}
bool get isValid {
if (promptController.text.trim().isEmpty) return false;
if (answerControllers.length < 2) return false;
for (final controller in answerControllers) {
if (controller.text.trim().isEmpty) return false;
}
if (correctAnswerIndices.isEmpty) return false;
if (correctAnswerIndices.any((idx) => idx < 0 || idx >= answerControllers.length)) {
return false;
}
return true;
}
Question toQuestion() {
return Question(
id: originalId ?? DateTime.now().millisecondsSinceEpoch.toString(),
prompt: promptController.text.trim(),
answers: answerControllers.map((c) => c.text.trim()).toList(),
correctAnswerIndices: correctAnswerIndices.toList()..sort(),
);
}
void dispose() {
promptController.dispose();
for (final controller in answerControllers) {
controller.dispose();
}
}
}

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

@ -0,0 +1,423 @@
import 'package:flutter/material.dart';
import 'package:practice_engine/practice_engine.dart';
import '../routes.dart';
import '../services/deck_storage.dart';
import '../data/default_deck.dart';
class DeckListScreen extends StatefulWidget {
const DeckListScreen({super.key});
@override
State<DeckListScreen> createState() => _DeckListScreenState();
}
class _DeckListScreenState extends State<DeckListScreen> {
final DeckStorage _deckStorage = DeckStorage();
List<Deck> _decks = [];
@override
void initState() {
super.initState();
_loadDecks();
}
void _loadDecks() {
setState(() {
_decks = _deckStorage.getAllDecks();
});
}
void _openDeck(Deck deck) {
Navigator.pushNamed(
context,
Routes.deckOverview,
arguments: deck,
).then((_) {
// Reload decks when returning from overview (in case deck was updated)
_loadDecks();
});
}
void _deleteDeck(Deck deck) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Deck'),
content: Text('Are you sure you want to delete "${deck.title}"? This action cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
style: FilledButton.styleFrom(
backgroundColor: Colors.red,
),
child: const Text('Delete'),
),
],
),
);
if (confirmed == true) {
_deckStorage.deleteDeck(deck.id);
_loadDecks();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${deck.title} deleted'),
backgroundColor: Colors.green,
),
);
}
}
}
void _navigateToImport() {
Navigator.pushNamed(context, Routes.deckImport).then((_) {
// Reload decks when returning from import
_loadDecks();
});
}
void _editDeck(Deck deck) {
Navigator.pushNamed(
context,
Routes.deckEdit,
arguments: deck,
).then((updatedDeck) {
if (updatedDeck != null) {
_loadDecks();
}
});
}
void _cloneDeck(Deck deck) {
// Create a copy of the deck with a new ID and reset progress
final clonedDeck = deck.copyWith(
id: '${deck.id}_clone_${DateTime.now().millisecondsSinceEpoch}',
title: '${deck.title} (Copy)',
currentAttemptIndex: 0,
attemptHistory: [],
questions: deck.questions.map((q) {
return q.copyWith(
consecutiveCorrect: 0,
isKnown: false,
priorityPoints: 0,
lastAttemptIndex: -1,
totalCorrectAttempts: 0,
totalAttempts: 0,
);
}).toList(),
);
_deckStorage.saveDeck(clonedDeck);
_loadDecks();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${deck.title} cloned successfully'),
backgroundColor: Colors.green,
),
);
}
}
void _showAddDeckOptions() {
showModalBottomSheet(
context: context,
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Add New Deck',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
// Import JSON
ListTile(
leading: const Icon(Icons.upload_file),
title: const Text('Import JSON'),
subtitle: const Text('Import a deck from JSON format'),
onTap: () {
Navigator.pop(context);
_navigateToImport();
},
),
const Divider(),
// Create Manually
ListTile(
leading: const Icon(Icons.edit),
title: const Text('Create Manually'),
subtitle: const Text('Create a new deck from scratch'),
onTap: () {
Navigator.pop(context);
Navigator.pushNamed(context, Routes.deckCreate).then((_) {
_loadDecks();
});
},
),
const Divider(),
// Use Default Quiz
ListTile(
leading: const Icon(Icons.quiz),
title: const Text('Use Default Quiz'),
subtitle: const Text('Add the default general knowledge quiz'),
onTap: () {
Navigator.pop(context);
_useDefaultDeck();
},
),
const SizedBox(height: 8),
],
),
),
),
);
}
void _useDefaultDeck() {
final defaultDeck = DefaultDeck.deck;
// Check if default deck already exists
if (_deckStorage.hasDeck(defaultDeck.id)) {
// If it exists, create a copy with a new ID
final clonedDeck = defaultDeck.copyWith(
id: '${defaultDeck.id}_${DateTime.now().millisecondsSinceEpoch}',
);
_deckStorage.saveDeck(clonedDeck);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Default deck added successfully'),
backgroundColor: Colors.green,
),
);
}
} else {
// Save default deck to storage
_deckStorage.saveDeck(defaultDeck);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Default deck added successfully'),
backgroundColor: Colors.green,
),
);
}
}
_loadDecks();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Decky'),
actions: [
IconButton(
icon: const Icon(Icons.add),
tooltip: 'Add Deck',
onPressed: _showAddDeckOptions,
),
],
),
body: _decks.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inbox,
size: 64,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
),
const SizedBox(height: 16),
Text(
'No decks yet',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Tap the + button to add a deck',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
)
: RefreshIndicator(
onRefresh: () async {
_loadDecks();
},
child: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _decks.length,
itemBuilder: (context, index) {
final deck = _decks[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
onTap: () => _openDeck(deck),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
deck.title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (deck.description.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
deck.description,
style: Theme.of(context).textTheme.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
child: const Row(
children: [
Icon(Icons.edit, color: Colors.blue),
SizedBox(width: 8),
Text('Edit'),
],
),
onTap: () {
Future.delayed(
const Duration(milliseconds: 100),
() => _editDeck(deck),
);
},
),
PopupMenuItem(
child: const Row(
children: [
Icon(Icons.copy, color: Colors.orange),
SizedBox(width: 8),
Text('Clone'),
],
),
onTap: () {
Future.delayed(
const Duration(milliseconds: 100),
() => _cloneDeck(deck),
);
},
),
PopupMenuItem(
child: const Row(
children: [
Icon(Icons.delete, color: Colors.red),
SizedBox(width: 8),
Text('Delete'),
],
),
onTap: () {
Future.delayed(
const Duration(milliseconds: 100),
() => _deleteDeck(deck),
);
},
),
],
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _StatChip(
icon: Icons.quiz,
label: '${deck.numberOfQuestions} questions',
),
),
const SizedBox(width: 8),
Expanded(
child: _StatChip(
icon: Icons.check_circle,
label: '${deck.knownCount} known',
),
),
const SizedBox(width: 8),
Expanded(
child: _StatChip(
icon: Icons.trending_up,
label: '${deck.practicePercentage.toStringAsFixed(0)}%',
),
),
],
),
],
),
),
),
);
},
),
),
);
}
}
class _StatChip extends StatelessWidget {
final IconData icon;
final String label;
const _StatChip({
required this.icon,
required this.label,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16),
const SizedBox(width: 4),
Flexible(
child: Text(
label,
style: Theme.of(context).textTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}

@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:practice_engine/practice_engine.dart';
import '../routes.dart';
import '../services/deck_storage.dart';
import '../widgets/attempts_chart.dart';
class DeckOverviewScreen extends StatefulWidget {
const DeckOverviewScreen({super.key});
@ -11,52 +13,53 @@ class DeckOverviewScreen extends StatefulWidget {
class _DeckOverviewScreenState extends State<DeckOverviewScreen> {
Deck? _deck;
final DeckStorage _deckStorage = DeckStorage();
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Get deck from route arguments or create sample
// Get deck from route arguments
final args = ModalRoute.of(context)?.settings.arguments;
if (args is Deck) {
// ALWAYS load the latest version from storage to ensure we have the most up-to-date deck
// This is critical for getting the latest incomplete attempt state
// Don't rely on route arguments - they might be stale
final storedDeck = _deckStorage.getDeck(args.id);
final deckToUse = storedDeck ?? args;
// Always update to get the latest state from storage
// This ensures we always have the most up-to-date incomplete attempt state
setState(() {
_deck = args;
});
} else if (_deck == null) {
// Only create sample if we don't have a deck yet
setState(() {
_deck = _createSampleDeck();
_deck = deckToUse;
});
}
}
void _saveDeck() {
if (_deck != null) {
_deckStorage.saveDeck(_deck!);
}
}
@override
void initState() {
super.initState();
}
Deck _createSampleDeck() {
const config = DeckConfig();
final questions = List.generate(20, (i) {
return Question(
id: 'q$i',
prompt: 'Sample Question $i?',
answers: ['Answer A', 'Answer B', 'Answer C', 'Answer D'],
correctAnswerIndex: i % 4,
isKnown: i < 5, // First 5 are known
);
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;
});
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,
);
deckToCheck = freshDeck; // Use the fresh deck for the check
}
}
void _startAttempt() {
if (_deck == null || _deck!.questions.isEmpty) {
if (deckToCheck == null || deckToCheck.questions.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Cannot start attempt: No questions in deck'),
@ -66,66 +69,221 @@ class _DeckOverviewScreenState extends State<DeckOverviewScreen> {
return;
}
// 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!,
);
}
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);
void _openConfig() {
if (_deck == null) return;
// Save to storage multiple times to ensure it's persisted
_deckStorage.saveDeck(updatedDeck);
_deckStorage.saveDeck(updatedDeck); // Save twice to be sure
Navigator.pushNamed(context, Routes.deckConfig, arguments: _deck)
.then((updatedDeck) {
if (updatedDeck != null && updatedDeck is Deck) {
// 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;
Future<void> _resetDeck() async {
final confirmed = await showDialog<bool>(
if (allKnown && includeKnownDisabled) {
// Show confirmation dialog
final shouldIncludeKnown = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Reset Deck Progress'),
title: const Text('All Questions Known'),
content: const Text(
'Are you sure you want to reset all progress? This will reset streaks, known status, and priorities for all questions.',
'All questions are known, attempt with known questions?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
child: const Text('No'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
style: FilledButton.styleFrom(
backgroundColor: Colors.red,
),
child: const Text('Reset'),
child: const Text('Yes'),
),
],
),
);
if (confirmed == true && _deck != null) {
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() {
if (_deck == null) return;
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 = DeckService.resetDeck(deck: _deck!, resetAttemptCounts: false);
_deck = updatedDeck;
});
_saveDeck(); // Save to storage
// Show confirmation message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Deck progress has been reset'),
SnackBar(
content: Text(wasReset
? 'Deck has been reset successfully'
: 'Deck settings have been updated'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
}
}
});
}
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,91 +295,82 @@ 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,
),
const SizedBox(height: 24),
CircularProgressIndicator(
SizedBox(
width: 80,
height: 80,
child: CircularProgressIndicator(
value: _deck!.practicePercentage / 100,
strokeWidth: 8,
strokeWidth: 6,
),
const SizedBox(height: 16),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_deck!.practicePercentage.toStringAsFixed(1)}%',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const SizedBox(height: 4),
Text(
'${_deck!.knownCount} of ${_deck!.numberOfQuestions} questions known',
style: Theme.of(context).textTheme.bodyMedium,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
),
const SizedBox(height: 24),
),
const SizedBox(height: 12),
// Statistics
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Statistics',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
Row(
padding: const EdgeInsets.all(12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_StatItem(
label: 'Total Questions',
label: 'Total',
value: '${_deck!.numberOfQuestions}',
icon: Icons.quiz,
),
@ -231,17 +380,103 @@ class _DeckOverviewScreenState extends State<DeckOverviewScreen> {
icon: Icons.check_circle,
),
_StatItem(
label: 'Needs Practice',
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: [
Row(
children: [
Icon(
Icons.history,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
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,
),
const SizedBox(height: 24),
_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: 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,46 @@ 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.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
}
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.titleLarge?.copyWith(
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
@ -297,3 +569,4 @@ class _StatItem extends StatelessWidget {
}
}

@ -0,0 +1,61 @@
import 'package:practice_engine/practice_engine.dart';
import '../data/default_deck.dart';
/// Service for managing deck storage and retrieval.
/// Currently uses in-memory storage. Can be extended to use persistent storage.
class DeckStorage {
static final DeckStorage _instance = DeckStorage._internal();
factory DeckStorage() => _instance;
DeckStorage._internal();
final Map<String, Deck> _decks = {};
bool _initialized = false;
/// Initialize storage with default deck if empty
void initialize() {
if (_initialized) return;
_initialized = true;
// Add default deck if no decks exist
if (_decks.isEmpty) {
final defaultDeck = DefaultDeck.deck;
_decks[defaultDeck.id] = defaultDeck;
}
}
/// Get all decks
List<Deck> getAllDecks() {
initialize();
return _decks.values.toList();
}
/// Get a deck by ID
Deck? getDeck(String id) {
initialize();
return _decks[id];
}
/// Add or update a deck
void saveDeck(Deck deck) {
initialize();
_decks[deck.id] = deck;
}
/// Delete a deck
void deleteDeck(String id) {
_decks.remove(id);
}
/// Check if a deck exists
bool hasDeck(String id) {
initialize();
return _decks.containsKey(id);
}
/// Get the number of decks
int get deckCount {
initialize();
return _decks.length;
}
}

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

@ -0,0 +1,255 @@
import 'package:flutter/material.dart';
import 'package:practice_engine/practice_engine.dart';
/// A chart widget that visualizes attempt progress over time.
class AttemptsChart extends StatelessWidget {
final List<AttemptHistoryEntry> attempts;
final int maxDisplayItems;
const AttemptsChart({
super.key,
required this.attempts,
this.maxDisplayItems = 15,
});
@override
Widget build(BuildContext context) {
if (attempts.isEmpty) {
return const SizedBox.shrink();
}
// Get recent attempts (most recent first, then reverse for display)
final displayAttempts = attempts.reversed.take(maxDisplayItems).toList();
if (displayAttempts.isEmpty) {
return const SizedBox.shrink();
}
// Find min and max values for scaling
final percentages = displayAttempts.map((e) => e.percentageCorrect).toList();
final minValue = percentages.reduce((a, b) => a < b ? a : b);
final maxValue = percentages.reduce((a, b) => a > b ? a : b);
final range = (maxValue - minValue).clamp(10.0, 100.0); // Ensure minimum range
final chartMin = (minValue - range * 0.1).clamp(0.0, 100.0);
final chartMax = (maxValue + range * 0.1).clamp(0.0, 100.0);
final chartRange = chartMax - chartMin;
return Container(
height: 220,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Y-axis labels
SizedBox(
width: 40,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${chartMax.toInt()}%',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontSize: 10,
),
),
Text(
'${((chartMin + chartMax) / 2).toInt()}%',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontSize: 10,
),
),
Text(
'${chartMin.toInt()}%',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontSize: 10,
),
),
],
),
),
// Chart area
Expanded(
child: Column(
children: [
Expanded(
child: CustomPaint(
painter: _AttemptsChartPainter(
attempts: displayAttempts,
chartMin: chartMin,
chartMax: chartMax,
chartRange: chartRange,
colorScheme: Theme.of(context).colorScheme,
),
child: Container(),
),
),
const SizedBox(height: 8),
// X-axis labels (attempt numbers)
SizedBox(
height: 20,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'1',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontSize: 10,
),
),
if (displayAttempts.length > 1)
Text(
'${displayAttempts.length}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontSize: 10,
),
),
],
),
),
],
),
),
],
),
);
}
}
class _AttemptsChartPainter extends CustomPainter {
final List<AttemptHistoryEntry> attempts;
final double chartMin;
final double chartMax;
final double chartRange;
final ColorScheme colorScheme;
_AttemptsChartPainter({
required this.attempts,
required this.chartMin,
required this.chartMax,
required this.chartRange,
required this.colorScheme,
});
@override
void paint(Canvas canvas, Size size) {
if (attempts.isEmpty) return;
final padding = 8.0;
final chartWidth = size.width - padding * 2;
final chartHeight = size.height - padding * 2;
final pointSpacing = attempts.length > 1
? chartWidth / (attempts.length - 1)
: chartWidth; // If only one point, center it
// Draw grid lines
_drawGridLines(canvas, size, padding, chartHeight);
// Draw line and points
final path = Path();
final points = <Offset>[];
for (int i = 0; i < attempts.length; i++) {
final percentage = attempts[i].percentageCorrect;
final normalizedValue = chartRange > 0
? ((percentage - chartMin) / chartRange).clamp(0.0, 1.0)
: 0.5; // If all values are the same, center vertically
final x = padding + (i * pointSpacing);
final y = padding + chartHeight - (normalizedValue * chartHeight);
final point = Offset(x, y);
points.add(point);
if (i == 0) {
path.moveTo(point.dx, point.dy);
} else {
path.lineTo(point.dx, point.dy);
}
}
// Draw the line
final linePaint = Paint()
..color = colorScheme.primary
..style = PaintingStyle.stroke
..strokeWidth = 2.5;
canvas.drawPath(path, linePaint);
// Draw points
final pointPaint = Paint()
..color = colorScheme.primary
..style = PaintingStyle.fill;
final selectedPointPaint = Paint()
..color = colorScheme.primaryContainer
..style = PaintingStyle.fill;
for (int i = 0; i < points.length; i++) {
final point = points[i];
// Draw point circle
final isRecent = i >= points.length - 3; // Highlight last 3 attempts
canvas.drawCircle(
point,
isRecent ? 5 : 4,
isRecent ? selectedPointPaint : pointPaint,
);
// Draw outer ring for better visibility
final ringPaint = Paint()
..color = colorScheme.primary.withValues(alpha: 0.3)
..style = PaintingStyle.stroke
..strokeWidth = 1;
canvas.drawCircle(point, isRecent ? 6 : 5, ringPaint);
}
// Draw average line (dashed)
final avgPercentage = attempts.map((e) => e.percentageCorrect).reduce((a, b) => a + b) / attempts.length;
final avgNormalized = ((avgPercentage - chartMin) / chartRange).clamp(0.0, 1.0);
final avgY = padding + chartHeight - (avgNormalized * chartHeight);
final avgLinePaint = Paint()
..color = colorScheme.secondary.withValues(alpha: 0.5)
..style = PaintingStyle.stroke
..strokeWidth = 1;
// Draw dashed line manually
final dashLength = 5.0;
final gapLength = 5.0;
double currentX = padding;
while (currentX < size.width - padding) {
canvas.drawLine(
Offset(currentX, avgY),
Offset((currentX + dashLength).clamp(padding, size.width - padding), avgY),
avgLinePaint,
);
currentX += dashLength + gapLength;
}
}
void _drawGridLines(Canvas canvas, Size size, double padding, double chartHeight) {
final gridPaint = Paint()
..color = colorScheme.onSurface.withValues(alpha: 0.1)
..style = PaintingStyle.stroke
..strokeWidth = 1;
// Draw horizontal grid lines (0%, 25%, 50%, 75%, 100%)
for (int i = 0; i <= 4; i++) {
final value = chartMin + (chartRange * i / 4);
final normalized = ((value - chartMin) / chartRange).clamp(0.0, 1.0);
final y = padding + chartHeight - (normalized * chartHeight);
canvas.drawLine(
Offset(padding, y),
Offset(size.width - padding, y),
gridPaint,
);
}
}
@override
bool shouldRepaint(_AttemptsChartPainter oldDelegate) {
return oldDelegate.attempts != attempts ||
oldDelegate.chartMin != chartMin ||
oldDelegate.chartMax != chartMax;
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.