ok
This commit is contained in:
+1157
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+2
-2
@@ -15,8 +15,8 @@ class DeckyApp extends StatelessWidget {
|
|||||||
title: 'Decky - Practice Engine',
|
title: 'Decky - Practice Engine',
|
||||||
theme: AppTheme.lightTheme,
|
theme: AppTheme.lightTheme,
|
||||||
darkTheme: AppTheme.darkTheme,
|
darkTheme: AppTheme.darkTheme,
|
||||||
themeMode: ThemeMode.system,
|
themeMode: ThemeMode.dark,
|
||||||
initialRoute: Routes.deckImport,
|
initialRoute: Routes.deckList,
|
||||||
routes: Routes.routes,
|
routes: Routes.routes,
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
);
|
);
|
||||||
|
|||||||
+10
-1
@@ -1,22 +1,31 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'screens/deck_list_screen.dart';
|
||||||
import 'screens/deck_import_screen.dart';
|
import 'screens/deck_import_screen.dart';
|
||||||
import 'screens/deck_overview_screen.dart';
|
import 'screens/deck_overview_screen.dart';
|
||||||
import 'screens/deck_config_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_screen.dart';
|
||||||
import 'screens/attempt_result_screen.dart';
|
import 'screens/attempt_result_screen.dart';
|
||||||
|
|
||||||
class Routes {
|
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 deckOverview = '/deck-overview';
|
||||||
static const String deckConfig = '/deck-config';
|
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 attempt = '/attempt';
|
||||||
static const String attemptResult = '/attempt-result';
|
static const String attemptResult = '/attempt-result';
|
||||||
|
|
||||||
static Map<String, WidgetBuilder> get routes {
|
static Map<String, WidgetBuilder> get routes {
|
||||||
return {
|
return {
|
||||||
|
deckList: (context) => const DeckListScreen(),
|
||||||
deckImport: (context) => const DeckImportScreen(),
|
deckImport: (context) => const DeckImportScreen(),
|
||||||
deckOverview: (context) => const DeckOverviewScreen(),
|
deckOverview: (context) => const DeckOverviewScreen(),
|
||||||
deckConfig: (context) => const DeckConfigScreen(),
|
deckConfig: (context) => const DeckConfigScreen(),
|
||||||
|
deckEdit: (context) => const DeckEditScreen(),
|
||||||
|
deckCreate: (context) => const DeckCreateScreen(),
|
||||||
attempt: (context) => const AttemptScreen(),
|
attempt: (context) => const AttemptScreen(),
|
||||||
attemptResult: (context) => const AttemptResultScreen(),
|
attemptResult: (context) => const AttemptResultScreen(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:practice_engine/practice_engine.dart';
|
import 'package:practice_engine/practice_engine.dart';
|
||||||
import '../routes.dart';
|
import '../routes.dart';
|
||||||
import '../widgets/status_chip.dart';
|
import '../widgets/status_chip.dart';
|
||||||
|
import '../services/deck_storage.dart';
|
||||||
|
|
||||||
class AttemptResultScreen extends StatefulWidget {
|
class AttemptResultScreen extends StatefulWidget {
|
||||||
const AttemptResultScreen({super.key});
|
const AttemptResultScreen({super.key});
|
||||||
@@ -13,6 +14,7 @@ class AttemptResultScreen extends StatefulWidget {
|
|||||||
class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
||||||
Deck? _deck;
|
Deck? _deck;
|
||||||
AttemptResult? _result;
|
AttemptResult? _result;
|
||||||
|
final DeckStorage _deckStorage = DeckStorage();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -70,12 +72,13 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
|||||||
|
|
||||||
void _done() {
|
void _done() {
|
||||||
if (_deck == null) return;
|
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(
|
Navigator.pushNamedAndRemoveUntil(
|
||||||
context,
|
context,
|
||||||
Routes.deckOverview,
|
Routes.deckList,
|
||||||
(route) => false,
|
(route) => false,
|
||||||
arguments: _deck,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,25 +177,25 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
|
|||||||
children: [
|
children: [
|
||||||
StatusChip(statusChange: answerResult.statusChange),
|
StatusChip(statusChange: answerResult.statusChange),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(
|
|
||||||
'Your answer: ${answerResult.question.answers[answerResult.userAnswerIndex]}',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.red,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
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(
|
style: TextStyle(
|
||||||
color: Colors.green,
|
color: Colors.red,
|
||||||
fontWeight: FontWeight.bold,
|
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(
|
subtitle: Text(
|
||||||
answerResult.isCorrect
|
answerResult.isCorrect
|
||||||
? 'Correct'
|
? 'Correct'
|
||||||
: 'Incorrect - Selected: ${answerResult.question.answers[answerResult.userAnswerIndex]}',
|
: 'Incorrect - Selected: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
|
||||||
),
|
),
|
||||||
trailing: StatusChip(statusChange: answerResult.statusChange),
|
trailing: StatusChip(statusChange: answerResult.statusChange),
|
||||||
),
|
),
|
||||||
|
|||||||
+649
-125
@@ -1,8 +1,65 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:ui';
|
||||||
import 'package:practice_engine/practice_engine.dart';
|
import 'package:practice_engine/practice_engine.dart';
|
||||||
import '../routes.dart';
|
import '../routes.dart';
|
||||||
import '../widgets/question_card.dart';
|
import '../widgets/question_card.dart';
|
||||||
import '../widgets/answer_option.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 {
|
class AttemptScreen extends StatefulWidget {
|
||||||
const AttemptScreen({super.key});
|
const AttemptScreen({super.key});
|
||||||
@@ -16,14 +73,21 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
Attempt? _attempt;
|
Attempt? _attempt;
|
||||||
AttemptService? _attemptService;
|
AttemptService? _attemptService;
|
||||||
int _currentQuestionIndex = 0;
|
int _currentQuestionIndex = 0;
|
||||||
int? _selectedAnswerIndex;
|
int? _selectedAnswerIndex; // For single answer questions (backward compatibility)
|
||||||
final Map<String, int> _answers = {};
|
Set<int> _selectedAnswerIndices = {}; // For multiple answer questions
|
||||||
final Map<String, bool> _manualOverrides = {};
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_attemptService = AttemptService();
|
_attemptService = AttemptService();
|
||||||
|
_pageController = PageController();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -32,11 +96,100 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
if (_deck == null) {
|
if (_deck == null) {
|
||||||
// Get deck from route arguments
|
// Get deck from route arguments
|
||||||
final args = ModalRoute.of(context)?.settings.arguments;
|
final args = ModalRoute.of(context)?.settings.arguments;
|
||||||
_deck = args is Deck ? args : _createSampleDeck();
|
bool? includeKnown;
|
||||||
_attempt = _attemptService!.createAttempt(deck: _deck!);
|
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() {
|
Deck _createSampleDeck() {
|
||||||
const config = DeckConfig();
|
const config = DeckConfig();
|
||||||
final questions = List.generate(10, (i) {
|
final questions = List.generate(10, (i) {
|
||||||
@@ -44,7 +197,7 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
id: 'q$i',
|
id: 'q$i',
|
||||||
prompt: 'Sample Question $i?',
|
prompt: 'Sample Question $i?',
|
||||||
answers: ['A', 'B', 'C', 'D'],
|
answers: ['A', 'B', 'C', 'D'],
|
||||||
correctAnswerIndex: i % 4,
|
correctAnswerIndices: [i % 4],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return Deck(
|
return Deck(
|
||||||
@@ -58,84 +211,180 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
|
|
||||||
Question get _currentQuestion => _attempt!.questions[_currentQuestionIndex];
|
Question get _currentQuestion => _attempt!.questions[_currentQuestionIndex];
|
||||||
bool get _isLastQuestion => _currentQuestionIndex == _attempt!.questions.length - 1;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _selectAnswer(int index) {
|
/// Returns true if at least one question has been answered (progress has been made)
|
||||||
|
bool get _hasAnyProgress => _answers.isNotEmpty || _manualOverrides.isNotEmpty;
|
||||||
|
|
||||||
|
void _goToPreviousQuestion() {
|
||||||
|
if (_currentQuestionIndex > 0) {
|
||||||
|
// Save current answer if any
|
||||||
|
if (_hasAnswer) {
|
||||||
|
if (_hasMultipleCorrect) {
|
||||||
|
_answers[_currentQuestion.id] = _selectedAnswerIndices.toList()..sort();
|
||||||
|
} else {
|
||||||
|
_answers[_currentQuestion.id] = _selectedAnswerIndex!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_pageController.previousPage(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _goToNextQuestion() {
|
||||||
|
if (_currentQuestionIndex < _attempt!.questions.length - 1) {
|
||||||
|
// Save current answer if any
|
||||||
|
if (_hasAnswer) {
|
||||||
|
if (_hasMultipleCorrect) {
|
||||||
|
_answers[_currentQuestion.id] = _selectedAnswerIndices.toList()..sort();
|
||||||
|
} else {
|
||||||
|
_answers[_currentQuestion.id] = _selectedAnswerIndex!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_pageController.nextPage(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTime(int seconds) {
|
||||||
|
final hours = seconds ~/ 3600;
|
||||||
|
final minutes = (seconds % 3600) ~/ 60;
|
||||||
|
final secs = seconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return '${hours}h ${minutes}m ${secs}s';
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return '${minutes}m ${secs}s';
|
||||||
|
} else {
|
||||||
|
return '${secs}s';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPageChanged(int index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedAnswerIndex = index;
|
_currentQuestionIndex = index;
|
||||||
|
_loadQuestionState();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (_deck != null && _deck!.config.immediateFeedbackEnabled) {
|
void _loadQuestionState() {
|
||||||
// Show feedback immediately
|
// 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() {
|
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) {
|
if (_isLastQuestion) {
|
||||||
_completeAttempt();
|
_completeAttempt();
|
||||||
} else {
|
} else {
|
||||||
setState(() {
|
_goToNextQuestion();
|
||||||
_currentQuestionIndex++;
|
|
||||||
_selectedAnswerIndex = null;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _markAsKnown() {
|
void _saveForLater() {
|
||||||
if (_deck == null) return;
|
if (_deck == null || _attempt == null) return;
|
||||||
setState(() {
|
|
||||||
_manualOverrides[_currentQuestion.id] = false; // Not needs practice
|
// Save current answer if selected
|
||||||
_deck = DeckService.markQuestionAsKnown(
|
if (_hasAnswer) {
|
||||||
deck: _deck!,
|
if (_hasMultipleCorrect) {
|
||||||
questionId: _currentQuestion.id,
|
_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(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Question marked as Known'),
|
content: Text('Attempt saved. You can continue later.'),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.blue,
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
void _markAsNeedsPractice() {
|
Navigator.pop(context);
|
||||||
if (_deck == null) return;
|
|
||||||
setState(() {
|
|
||||||
_manualOverrides[_currentQuestion.id] = true;
|
|
||||||
_deck = DeckService.markQuestionAsNeedsPractice(
|
|
||||||
deck: _deck!,
|
|
||||||
questionId: _currentQuestion.id,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Question marked as Needs Practice'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _completeAttempt() {
|
void _completeAttempt() {
|
||||||
if (_deck == null || _attempt == null || _attemptService == null) return;
|
if (_deck == null || _attempt == null || _attemptService == null) return;
|
||||||
|
|
||||||
|
final endTime = DateTime.now().millisecondsSinceEpoch;
|
||||||
final result = _attemptService!.processAttempt(
|
final result = _attemptService!.processAttempt(
|
||||||
deck: _deck!,
|
deck: _deck!,
|
||||||
attempt: _attempt!,
|
attempt: _attempt!,
|
||||||
answers: _answers,
|
answers: _answers,
|
||||||
manualOverrides: _manualOverrides,
|
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(
|
Navigator.pushReplacementNamed(
|
||||||
context,
|
context,
|
||||||
Routes.attemptResult,
|
Routes.attemptResult,
|
||||||
arguments: {
|
arguments: {
|
||||||
'deck': result.updatedDeck,
|
'deck': updatedDeckWithHistory,
|
||||||
'result': result.result,
|
'result': result.result,
|
||||||
'attempt': _attempt,
|
'attempt': _attempt,
|
||||||
},
|
},
|
||||||
@@ -150,18 +399,91 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return PopScope(
|
||||||
appBar: AppBar(
|
canPop: false,
|
||||||
title: Text('Attempt - ${_deck!.title}'),
|
onPopInvoked: (bool didPop) async {
|
||||||
),
|
if (didPop) return;
|
||||||
body: Column(
|
// Only prompt to save if there's actual progress (at least one question answered)
|
||||||
children: [
|
if (!_hasAnyProgress) {
|
||||||
// Progress Indicator
|
// No progress made, just allow exit without prompt
|
||||||
LinearProgressIndicator(
|
if (mounted) {
|
||||||
value: (_currentQuestionIndex + 1) / _attempt!.questions.length,
|
Navigator.of(context).pop();
|
||||||
minHeight: 4,
|
}
|
||||||
|
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(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -171,76 +493,289 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
'Question ${_currentQuestionIndex + 1} of ${_attempt!.questions.length}',
|
'Question ${_currentQuestionIndex + 1} of ${_attempt!.questions.length}',
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
if (_currentQuestion.isKnown)
|
Row(
|
||||||
Chip(
|
children: [
|
||||||
label: const Text('Known'),
|
if (_deck!.config.timeLimitSeconds != null)
|
||||||
avatar: const Icon(Icons.check_circle, size: 18),
|
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(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: PageView.builder(
|
||||||
padding: const EdgeInsets.all(16),
|
controller: _pageController,
|
||||||
child: Column(
|
onPageChanged: _onPageChanged,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
itemCount: _attempt!.questions.length,
|
||||||
children: [
|
physics: const BouncingScrollPhysics(), // Always allow swiping, validation happens in onPageChanged
|
||||||
QuestionCard(question: _currentQuestion),
|
itemBuilder: (context, index) {
|
||||||
const SizedBox(height: 24),
|
final question = _attempt!.questions[index];
|
||||||
|
final isMultipleCorrect = question.hasMultipleCorrectAnswers;
|
||||||
|
final savedAnswer = _answers[question.id];
|
||||||
|
|
||||||
// Answer Options
|
// Determine selected answers for this question
|
||||||
...List.generate(
|
int? selectedIndex;
|
||||||
_currentQuestion.answers.length,
|
Set<int> selectedIndices = {};
|
||||||
(index) => Padding(
|
if (savedAnswer != null) {
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
if (savedAnswer is int) {
|
||||||
child: AnswerOption(
|
selectedIndex = savedAnswer;
|
||||||
text: _currentQuestion.answers[index],
|
} else if (savedAnswer is List<int>) {
|
||||||
isSelected: _selectedAnswerIndex == index,
|
selectedIndices = savedAnswer.toSet();
|
||||||
onTap: () => _selectAnswer(index),
|
}
|
||||||
isCorrect: _deck != null && _deck!.config.immediateFeedbackEnabled &&
|
}
|
||||||
_selectedAnswerIndex == index
|
|
||||||
? index == _currentQuestion.correctAnswerIndex
|
return AnimatedBuilder(
|
||||||
: null,
|
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),
|
// Answer Options
|
||||||
|
...List.generate(
|
||||||
// Manual Override Buttons
|
question.answers.length,
|
||||||
Card(
|
(answerIndex) => Padding(
|
||||||
child: Padding(
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
padding: const EdgeInsets.all(16),
|
child: AnswerOption(
|
||||||
child: Column(
|
text: question.answers[answerIndex],
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
isSelected: isMultipleCorrect
|
||||||
children: [
|
? selectedIndices.contains(answerIndex)
|
||||||
Text(
|
: selectedIndex == answerIndex,
|
||||||
'Manual Override',
|
onTap: () {
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
// 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: [
|
children: [
|
||||||
Expanded(
|
Text(
|
||||||
child: OutlinedButton.icon(
|
'Manual Override',
|
||||||
onPressed: _markAsKnown,
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
icon: const Icon(Icons.check_circle),
|
|
||||||
label: const Text('Mark as Known'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(height: 12),
|
||||||
Expanded(
|
Row(
|
||||||
child: OutlinedButton.icon(
|
children: [
|
||||||
onPressed: _markAsNeedsPractice,
|
Expanded(
|
||||||
icon: const Icon(Icons.school),
|
child: OutlinedButton.icon(
|
||||||
label: const Text('Needs Practice'),
|
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:flutter/material.dart';
|
||||||
import 'package:practice_engine/practice_engine.dart';
|
import 'package:practice_engine/practice_engine.dart';
|
||||||
|
import '../services/deck_storage.dart';
|
||||||
|
import '../routes.dart';
|
||||||
|
|
||||||
class DeckConfigScreen extends StatefulWidget {
|
class DeckConfigScreen extends StatefulWidget {
|
||||||
const DeckConfigScreen({super.key});
|
const DeckConfigScreen({super.key});
|
||||||
@@ -15,23 +17,46 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
late TextEditingController _attemptSizeController;
|
late TextEditingController _attemptSizeController;
|
||||||
late TextEditingController _priorityIncreaseController;
|
late TextEditingController _priorityIncreaseController;
|
||||||
late TextEditingController _priorityDecreaseController;
|
late TextEditingController _priorityDecreaseController;
|
||||||
|
late TextEditingController _timeLimitHoursController;
|
||||||
|
late TextEditingController _timeLimitMinutesController;
|
||||||
|
late TextEditingController _timeLimitSecondsController;
|
||||||
late bool _immediateFeedback;
|
late bool _immediateFeedback;
|
||||||
|
late bool _includeKnownInAttempts;
|
||||||
|
bool _timeLimitEnabled = false;
|
||||||
|
String? _lastDeckId;
|
||||||
|
int _configHashCode = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_deck = null;
|
_deck = null;
|
||||||
|
_lastDeckId = null;
|
||||||
|
_configHashCode = 0;
|
||||||
|
// Initialize controllers with default values
|
||||||
|
_timeLimitHoursController = TextEditingController();
|
||||||
|
_timeLimitMinutesController = TextEditingController();
|
||||||
|
_timeLimitSecondsController = TextEditingController();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
void _initializeFromDeck(Deck deck) {
|
||||||
void didChangeDependencies() {
|
// Dispose old controllers if they exist
|
||||||
super.didChangeDependencies();
|
if (_deck != null) {
|
||||||
if (_deck == null) {
|
_consecutiveController.dispose();
|
||||||
// Get deck from route arguments
|
_attemptSizeController.dispose();
|
||||||
final args = ModalRoute.of(context)?.settings.arguments;
|
_priorityIncreaseController.dispose();
|
||||||
_deck = args is Deck ? args : _createSampleDeck();
|
_priorityDecreaseController.dispose();
|
||||||
_config = _deck!.config;
|
_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(
|
_consecutiveController = TextEditingController(
|
||||||
text: _config!.requiredConsecutiveCorrect.toString(),
|
text: _config!.requiredConsecutiveCorrect.toString(),
|
||||||
);
|
);
|
||||||
@@ -45,6 +70,41 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
text: _config!.priorityDecreaseOnCorrect.toString(),
|
text: _config!.priorityDecreaseOnCorrect.toString(),
|
||||||
);
|
);
|
||||||
_immediateFeedback = _config!.immediateFeedbackEnabled;
|
_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();
|
_attemptSizeController.dispose();
|
||||||
_priorityIncreaseController.dispose();
|
_priorityIncreaseController.dispose();
|
||||||
_priorityDecreaseController.dispose();
|
_priorityDecreaseController.dispose();
|
||||||
|
_timeLimitHoursController.dispose();
|
||||||
|
_timeLimitMinutesController.dispose();
|
||||||
|
_timeLimitSecondsController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,16 +164,49 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
return;
|
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,
|
requiredConsecutiveCorrect: consecutive,
|
||||||
defaultAttemptSize: attemptSize,
|
defaultAttemptSize: attemptSize,
|
||||||
priorityIncreaseOnIncorrect: priorityIncrease,
|
priorityIncreaseOnIncorrect: priorityIncrease,
|
||||||
priorityDecreaseOnCorrect: priorityDecrease,
|
priorityDecreaseOnCorrect: priorityDecrease,
|
||||||
immediateFeedbackEnabled: _immediateFeedback,
|
immediateFeedbackEnabled: _immediateFeedback,
|
||||||
|
includeKnownInAttempts: _includeKnownInAttempts,
|
||||||
|
timeLimitSeconds: timeLimitSeconds,
|
||||||
);
|
);
|
||||||
|
|
||||||
final updatedDeck = _deck!.copyWith(config: updatedConfig);
|
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);
|
Navigator.pop(context, updatedDeck);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +214,84 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
Navigator.pop(context);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_deck == null || _config == null) {
|
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 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:practice_engine/practice_engine.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 {
|
class DeckImportScreen extends StatefulWidget {
|
||||||
const DeckImportScreen({super.key});
|
const DeckImportScreen({super.key});
|
||||||
@@ -33,12 +34,27 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
|
|||||||
priorityIncreaseOnIncorrect: configJson['priorityIncreaseOnIncorrect'] as int? ?? 5,
|
priorityIncreaseOnIncorrect: configJson['priorityIncreaseOnIncorrect'] as int? ?? 5,
|
||||||
priorityDecreaseOnCorrect: configJson['priorityDecreaseOnCorrect'] as int? ?? 2,
|
priorityDecreaseOnCorrect: configJson['priorityDecreaseOnCorrect'] as int? ?? 2,
|
||||||
immediateFeedbackEnabled: configJson['immediateFeedbackEnabled'] as bool? ?? true,
|
immediateFeedbackEnabled: configJson['immediateFeedbackEnabled'] as bool? ?? true,
|
||||||
|
includeKnownInAttempts: configJson['includeKnownInAttempts'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Parse questions
|
// Parse questions
|
||||||
final questionsJson = json['questions'] as List<dynamic>? ?? [];
|
final questionsJson = json['questions'] as List<dynamic>? ?? [];
|
||||||
final questions = questionsJson.map((qJson) {
|
final questions = questionsJson.map((qJson) {
|
||||||
final questionMap = qJson as Map<String, dynamic>;
|
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(
|
return Question(
|
||||||
id: questionMap['id'] as String? ?? '',
|
id: questionMap['id'] as String? ?? '',
|
||||||
prompt: questionMap['prompt'] as String? ?? '',
|
prompt: questionMap['prompt'] as String? ?? '',
|
||||||
@@ -46,7 +62,7 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
|
|||||||
?.map((e) => e.toString())
|
?.map((e) => e.toString())
|
||||||
.toList() ??
|
.toList() ??
|
||||||
[],
|
[],
|
||||||
correctAnswerIndex: questionMap['correctAnswerIndex'] as int? ?? 0,
|
correctAnswerIndices: correctIndices ?? [0],
|
||||||
consecutiveCorrect: questionMap['consecutiveCorrect'] as int? ?? 0,
|
consecutiveCorrect: questionMap['consecutiveCorrect'] as int? ?? 0,
|
||||||
isKnown: questionMap['isKnown'] as bool? ?? false,
|
isKnown: questionMap['isKnown'] as bool? ?? false,
|
||||||
priorityPoints: questionMap['priorityPoints'] as int? ?? 0,
|
priorityPoints: questionMap['priorityPoints'] as int? ?? 0,
|
||||||
@@ -91,12 +107,12 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
|
|||||||
throw FormatException('Deck must contain at least one question');
|
throw FormatException('Deck must contain at least one question');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to deck overview with the imported deck
|
// Save deck to storage
|
||||||
Navigator.pushReplacementNamed(
|
final deckStorage = DeckStorage();
|
||||||
context,
|
deckStorage.saveDeck(deck);
|
||||||
Routes.deckOverview,
|
|
||||||
arguments: deck,
|
// Navigate back to deck list
|
||||||
);
|
Navigator.pop(context);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage = e.toString();
|
_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() {
|
void _loadSampleDeck() {
|
||||||
final sampleJson = {
|
final sampleJson = {
|
||||||
'id': 'sample-deck',
|
'id': 'sample-deck',
|
||||||
@@ -116,13 +161,14 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
|
|||||||
'priorityIncreaseOnIncorrect': 5,
|
'priorityIncreaseOnIncorrect': 5,
|
||||||
'priorityDecreaseOnCorrect': 2,
|
'priorityDecreaseOnCorrect': 2,
|
||||||
'immediateFeedbackEnabled': true,
|
'immediateFeedbackEnabled': true,
|
||||||
|
'includeKnownInAttempts': false,
|
||||||
},
|
},
|
||||||
'questions': List.generate(20, (i) {
|
'questions': List.generate(20, (i) {
|
||||||
return {
|
return {
|
||||||
'id': 'q$i',
|
'id': 'q$i',
|
||||||
'prompt': 'Sample Question $i?',
|
'prompt': 'Sample Question $i?',
|
||||||
'answers': ['Answer A', 'Answer B', 'Answer C', 'Answer D'],
|
'answers': ['Answer A', 'Answer B', 'Answer C', 'Answer D'],
|
||||||
'correctAnswerIndex': i % 4,
|
'correctAnswerIndices': [i % 4],
|
||||||
'isKnown': i < 5,
|
'isKnown': i < 5,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -142,6 +188,79 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
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(
|
Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -272,7 +391,8 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
|
|||||||
const Text('• id: string (required)'),
|
const Text('• id: string (required)'),
|
||||||
const Text('• prompt: string (required)'),
|
const Text('• prompt: string (required)'),
|
||||||
const Text('• answers: array of strings (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('• isKnown: boolean (optional)'),
|
||||||
const Text('• consecutiveCorrect: number (optional)'),
|
const Text('• consecutiveCorrect: number (optional)'),
|
||||||
const Text('• priorityPoints: number (optional)'),
|
const Text('• priorityPoints: number (optional)'),
|
||||||
@@ -287,6 +407,7 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
|
|||||||
const Text('• priorityIncreaseOnIncorrect: number'),
|
const Text('• priorityIncreaseOnIncorrect: number'),
|
||||||
const Text('• priorityDecreaseOnCorrect: number'),
|
const Text('• priorityDecreaseOnCorrect: number'),
|
||||||
const Text('• immediateFeedbackEnabled: boolean'),
|
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:flutter/material.dart';
|
||||||
import 'package:practice_engine/practice_engine.dart';
|
import 'package:practice_engine/practice_engine.dart';
|
||||||
import '../routes.dart';
|
import '../routes.dart';
|
||||||
|
import '../services/deck_storage.dart';
|
||||||
|
import '../widgets/attempts_chart.dart';
|
||||||
|
|
||||||
class DeckOverviewScreen extends StatefulWidget {
|
class DeckOverviewScreen extends StatefulWidget {
|
||||||
const DeckOverviewScreen({super.key});
|
const DeckOverviewScreen({super.key});
|
||||||
@@ -11,52 +13,53 @@ class DeckOverviewScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _DeckOverviewScreenState extends State<DeckOverviewScreen> {
|
class _DeckOverviewScreenState extends State<DeckOverviewScreen> {
|
||||||
Deck? _deck;
|
Deck? _deck;
|
||||||
|
final DeckStorage _deckStorage = DeckStorage();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
// Get deck from route arguments or create sample
|
// Get deck from route arguments
|
||||||
final args = ModalRoute.of(context)?.settings.arguments;
|
final args = ModalRoute.of(context)?.settings.arguments;
|
||||||
if (args is Deck) {
|
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(() {
|
setState(() {
|
||||||
_deck = args;
|
_deck = deckToUse;
|
||||||
});
|
|
||||||
} else if (_deck == null) {
|
|
||||||
// Only create sample if we don't have a deck yet
|
|
||||||
setState(() {
|
|
||||||
_deck = _createSampleDeck();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _saveDeck() {
|
||||||
|
if (_deck != null) {
|
||||||
|
_deckStorage.saveDeck(_deck!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
Deck _createSampleDeck() {
|
void _startAttempt() async {
|
||||||
const config = DeckConfig();
|
// Force reload from storage before checking for incomplete attempts to ensure we have latest state
|
||||||
final questions = List.generate(20, (i) {
|
Deck? deckToCheck = _deck;
|
||||||
return Question(
|
if (_deck != null) {
|
||||||
id: 'q$i',
|
final freshDeck = _deckStorage.getDeck(_deck!.id);
|
||||||
prompt: 'Sample Question $i?',
|
if (freshDeck != null) {
|
||||||
answers: ['Answer A', 'Answer B', 'Answer C', 'Answer D'],
|
setState(() {
|
||||||
correctAnswerIndex: i % 4,
|
_deck = freshDeck;
|
||||||
isKnown: i < 5, // First 5 are known
|
});
|
||||||
);
|
deckToCheck = freshDeck; // Use the fresh deck for the check
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Deck(
|
if (deckToCheck == null || deckToCheck.questions.isEmpty) {
|
||||||
id: 'sample-deck',
|
|
||||||
title: 'Sample Practice Deck',
|
|
||||||
description: 'This is a sample deck for practicing. It contains various questions to help you learn.',
|
|
||||||
questions: questions,
|
|
||||||
config: config,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _startAttempt() {
|
|
||||||
if (_deck == null || _deck!.questions.isEmpty) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Cannot start attempt: No questions in deck'),
|
content: Text('Cannot start attempt: No questions in deck'),
|
||||||
@@ -66,11 +69,170 @@ class _DeckOverviewScreenState extends State<DeckOverviewScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Navigator.pushNamed(
|
// Check for incomplete attempt first - use the fresh deck we just loaded
|
||||||
context,
|
if (deckToCheck.incompleteAttempt != null) {
|
||||||
Routes.attempt,
|
final incomplete = deckToCheck.incompleteAttempt!;
|
||||||
arguments: _deck!,
|
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() {
|
void _openConfig() {
|
||||||
@@ -79,53 +241,49 @@ class _DeckOverviewScreenState extends State<DeckOverviewScreen> {
|
|||||||
Navigator.pushNamed(context, Routes.deckConfig, arguments: _deck)
|
Navigator.pushNamed(context, Routes.deckConfig, arguments: _deck)
|
||||||
.then((updatedDeck) {
|
.then((updatedDeck) {
|
||||||
if (updatedDeck != null && updatedDeck is Deck) {
|
if (updatedDeck != null && updatedDeck is Deck) {
|
||||||
|
final wasReset = updatedDeck.attemptHistory.isEmpty &&
|
||||||
|
updatedDeck.currentAttemptIndex == 0 &&
|
||||||
|
_deck != null &&
|
||||||
|
_deck!.attemptHistory.isNotEmpty;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_deck = updatedDeck;
|
_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 {
|
String _formatTime(int milliseconds) {
|
||||||
final confirmed = await showDialog<bool>(
|
final seconds = milliseconds ~/ 1000;
|
||||||
context: context,
|
final minutes = seconds ~/ 60;
|
||||||
builder: (context) => AlertDialog(
|
final hours = minutes ~/ 60;
|
||||||
title: const Text('Reset Deck Progress'),
|
final remainingMinutes = minutes % 60;
|
||||||
content: const Text(
|
final remainingSeconds = seconds % 60;
|
||||||
'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) {
|
if (hours > 0) {
|
||||||
setState(() {
|
return '${hours}h ${remainingMinutes}m';
|
||||||
_deck = DeckService.resetDeck(deck: _deck!, resetAttemptCounts: false);
|
} else if (minutes > 0) {
|
||||||
});
|
return '${minutes}m ${remainingSeconds}s';
|
||||||
|
} else {
|
||||||
if (mounted) {
|
return '${remainingSeconds}s';
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Deck progress has been reset'),
|
|
||||||
backgroundColor: Colors.green,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_deck == null) {
|
if (_deck == null) {
|
||||||
@@ -137,111 +295,188 @@ class _DeckOverviewScreenState extends State<DeckOverviewScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(_deck!.title),
|
title: Text(_deck!.title),
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
tooltip: 'Reset Progress',
|
|
||||||
onPressed: _resetDeck,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// Deck Description
|
// Deck Description
|
||||||
Card(
|
Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Description',
|
'Description',
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
_deck!.description,
|
_deck!.description,
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Practice Progress
|
// Practice Progress
|
||||||
Card(
|
Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
SizedBox(
|
||||||
'Practice Progress',
|
width: 80,
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
height: 80,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value: _deck!.practicePercentage / 100,
|
||||||
|
strokeWidth: 6,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(width: 16),
|
||||||
CircularProgressIndicator(
|
Expanded(
|
||||||
value: _deck!.practicePercentage / 100,
|
child: Column(
|
||||||
strokeWidth: 8,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
),
|
children: [
|
||||||
const SizedBox(height: 16),
|
Text(
|
||||||
Text(
|
'${_deck!.practicePercentage.toStringAsFixed(1)}%',
|
||||||
'${_deck!.practicePercentage.toStringAsFixed(1)}%',
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
fontWeight: FontWeight.bold,
|
||||||
fontWeight: FontWeight.bold,
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 4),
|
||||||
const SizedBox(height: 8),
|
Text(
|
||||||
Text(
|
'${_deck!.knownCount} of ${_deck!.numberOfQuestions} questions known',
|
||||||
'${_deck!.knownCount} of ${_deck!.numberOfQuestions} questions known',
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Statistics
|
// 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(
|
Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
|
||||||
'Statistics',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
||||||
children: [
|
children: [
|
||||||
_StatItem(
|
Icon(
|
||||||
label: 'Total Questions',
|
Icons.history,
|
||||||
value: '${_deck!.numberOfQuestions}',
|
size: 20,
|
||||||
icon: Icons.quiz,
|
color: Theme.of(context).colorScheme.primary,
|
||||||
),
|
),
|
||||||
_StatItem(
|
const SizedBox(width: 8),
|
||||||
label: 'Known',
|
Text(
|
||||||
value: '${_deck!.knownCount}',
|
'Attempts History',
|
||||||
icon: Icons.check_circle,
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
_StatItem(
|
),
|
||||||
label: 'Needs Practice',
|
|
||||||
value: '${_deck!.numberOfQuestions - _deck!.knownCount}',
|
|
||||||
icon: Icons.school,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
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
|
// Action Buttons
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
@@ -249,14 +484,17 @@ class _DeckOverviewScreenState extends State<DeckOverviewScreen> {
|
|||||||
icon: const Icon(Icons.play_arrow),
|
icon: const Icon(Icons.play_arrow),
|
||||||
label: const Text('Start Attempt'),
|
label: const Text('Start Attempt'),
|
||||||
style: FilledButton.styleFrom(
|
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(
|
OutlinedButton.icon(
|
||||||
onPressed: _openConfig,
|
onPressed: _openConfig,
|
||||||
icon: const Icon(Icons.settings),
|
icon: const Icon(Icons.settings),
|
||||||
label: const Text('Configure Deck'),
|
label: const Text('Configure Deck'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -279,12 +517,13 @@ class _StatItem extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 32),
|
Icon(icon, size: 24),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -297,3 +536,37 @@ class _StatItem extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _HistoryStatItem extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
const _HistoryStatItem({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import 'package:practice_engine/practice_engine.dart';
|
||||||
|
import '../data/default_deck.dart';
|
||||||
|
|
||||||
|
/// Service for managing deck storage and retrieval.
|
||||||
|
/// Currently uses in-memory storage. Can be extended to use persistent storage.
|
||||||
|
class DeckStorage {
|
||||||
|
static final DeckStorage _instance = DeckStorage._internal();
|
||||||
|
factory DeckStorage() => _instance;
|
||||||
|
DeckStorage._internal();
|
||||||
|
|
||||||
|
final Map<String, Deck> _decks = {};
|
||||||
|
bool _initialized = false;
|
||||||
|
|
||||||
|
/// Initialize storage with default deck if empty
|
||||||
|
void initialize() {
|
||||||
|
if (_initialized) return;
|
||||||
|
_initialized = true;
|
||||||
|
|
||||||
|
// Add default deck if no decks exist
|
||||||
|
if (_decks.isEmpty) {
|
||||||
|
final defaultDeck = DefaultDeck.deck;
|
||||||
|
_decks[defaultDeck.id] = defaultDeck;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all decks
|
||||||
|
List<Deck> getAllDecks() {
|
||||||
|
initialize();
|
||||||
|
return _decks.values.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a deck by ID
|
||||||
|
Deck? getDeck(String id) {
|
||||||
|
initialize();
|
||||||
|
return _decks[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add or update a deck
|
||||||
|
void saveDeck(Deck deck) {
|
||||||
|
initialize();
|
||||||
|
_decks[deck.id] = deck;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a deck
|
||||||
|
void deleteDeck(String id) {
|
||||||
|
_decks.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a deck exists
|
||||||
|
bool hasDeck(String id) {
|
||||||
|
initialize();
|
||||||
|
return _decks.containsKey(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of decks
|
||||||
|
int get deckCount {
|
||||||
|
initialize();
|
||||||
|
return _decks.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -5,6 +5,7 @@ class AnswerOption extends StatelessWidget {
|
|||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final bool? isCorrect;
|
final bool? isCorrect;
|
||||||
|
final bool isMultipleChoice;
|
||||||
|
|
||||||
const AnswerOption({
|
const AnswerOption({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -12,6 +13,7 @@ class AnswerOption extends StatelessWidget {
|
|||||||
required this.isSelected,
|
required this.isSelected,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
this.isCorrect,
|
this.isCorrect,
|
||||||
|
this.isMultipleChoice = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -63,12 +65,12 @@ class AnswerOption extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
if (isSelected && icon == null)
|
if (isSelected && icon == null)
|
||||||
Icon(
|
Icon(
|
||||||
Icons.radio_button_checked,
|
isMultipleChoice ? Icons.check_box : Icons.radio_button_checked,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
color: Theme.of(context).colorScheme.primary,
|
||||||
)
|
)
|
||||||
else if (!isSelected)
|
else if (!isSelected)
|
||||||
Icon(
|
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),
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user