shufle order
This commit is contained in:
@@ -47,6 +47,23 @@ dart pub get
|
|||||||
|
|
||||||
- Dart SDK >= 3.0.0
|
- Dart SDK >= 3.0.0
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
From the project root, use this order of commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Get dependencies
|
||||||
|
flutter pub get
|
||||||
|
|
||||||
|
# 2. Run tests (package tests from packages/practice_engine)
|
||||||
|
cd packages/practice_engine && dart test && cd ../..
|
||||||
|
|
||||||
|
# 3. Run the app
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `flutter run -d <device-id>` to pick a device (e.g. `chrome` for web). Run `flutter devices` to list available targets.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Creating a Deck
|
### Creating a Deck
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../utils/top_snackbar.dart';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
@@ -77,6 +78,8 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
Set<int> _selectedAnswerIndices = {}; // For multiple answer questions
|
Set<int> _selectedAnswerIndices = {}; // For multiple answer questions
|
||||||
Map<String, dynamic> _answers = {}; // Can store int or List<int>
|
Map<String, dynamic> _answers = {}; // Can store int or List<int>
|
||||||
Map<String, bool> _manualOverrides = {};
|
Map<String, bool> _manualOverrides = {};
|
||||||
|
/// Display order of answer indices per question (shuffled for new attempts).
|
||||||
|
Map<String, List<int>> _answerOrderPerQuestion = {};
|
||||||
final DeckStorage _deckStorage = DeckStorage();
|
final DeckStorage _deckStorage = DeckStorage();
|
||||||
late PageController _pageController;
|
late PageController _pageController;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
@@ -117,10 +120,13 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
_currentQuestionIndex = incomplete.currentQuestionIndex;
|
_currentQuestionIndex = incomplete.currentQuestionIndex;
|
||||||
_answers = Map<String, dynamic>.from(incomplete.answers);
|
_answers = Map<String, dynamic>.from(incomplete.answers);
|
||||||
_manualOverrides = Map<String, bool>.from(incomplete.manualOverrides);
|
_manualOverrides = Map<String, bool>.from(incomplete.manualOverrides);
|
||||||
|
// Resumed attempt: use identity order so saved answers (original indices) match
|
||||||
|
_answerOrderPerQuestion = {
|
||||||
|
for (final q in _attempt!.questions)
|
||||||
|
q.id: List.generate(q.answers.length, (i) => i),
|
||||||
|
};
|
||||||
// Restore selected answer for current question
|
// Restore selected answer for current question
|
||||||
_loadQuestionState();
|
_loadQuestionState();
|
||||||
|
|
||||||
// Initialize PageController to the current question index
|
// Initialize PageController to the current question index
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (_pageController.hasClients) {
|
if (_pageController.hasClients) {
|
||||||
@@ -132,6 +138,16 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
deck: _deck!,
|
deck: _deck!,
|
||||||
includeKnown: includeKnown,
|
includeKnown: includeKnown,
|
||||||
);
|
);
|
||||||
|
// New attempt: randomize answer order per question when deck config enables it
|
||||||
|
_answerOrderPerQuestion = {};
|
||||||
|
final shuffleAnswers = _deck!.config.shuffleAnswerOrder;
|
||||||
|
for (final q in _attempt!.questions) {
|
||||||
|
final order = List.generate(q.answers.length, (i) => i);
|
||||||
|
if (shuffleAnswers) {
|
||||||
|
order.shuffle(math.Random());
|
||||||
|
}
|
||||||
|
_answerOrderPerQuestion[q.id] = order;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize timer if time limit is set
|
// Initialize timer if time limit is set
|
||||||
@@ -451,12 +467,11 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
final updatedDeck = _deck!.copyWith(incompleteAttempt: incompleteAttempt);
|
final updatedDeck = _deck!.copyWith(incompleteAttempt: incompleteAttempt);
|
||||||
_deckStorage.saveDeckSync(updatedDeck);
|
_deckStorage.saveDeckSync(updatedDeck);
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showTopSnackBar(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text('Attempt saved. You can continue later.'),
|
message: 'Attempt saved. You can continue later.',
|
||||||
backgroundColor: Colors.blue,
|
backgroundColor: Colors.blue,
|
||||||
duration: Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
@@ -709,58 +724,62 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
QuestionCard(question: question),
|
QuestionCard(question: question),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Answer Options
|
// Answer Options (displayed in shuffled order; we store original indices)
|
||||||
...List.generate(
|
...() {
|
||||||
question.answers.length,
|
final order = _answerOrderPerQuestion[question.id] ??
|
||||||
(answerIndex) => Padding(
|
List.generate(question.answers.length, (i) => i);
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
return List.generate(
|
||||||
child: AnswerOption(
|
question.answers.length,
|
||||||
text: question.answers[answerIndex],
|
(displayIndex) {
|
||||||
isSelected: isMultipleCorrect
|
final originalIndex = order[displayIndex];
|
||||||
? selectedIndices.contains(answerIndex)
|
return Padding(
|
||||||
: selectedIndex == answerIndex,
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
onTap: () {
|
child: AnswerOption(
|
||||||
// Update selection for this question
|
text: question.answers[originalIndex],
|
||||||
setState(() {
|
isSelected: isMultipleCorrect
|
||||||
if (isMultipleCorrect) {
|
? selectedIndices.contains(originalIndex)
|
||||||
if (selectedIndices.contains(answerIndex)) {
|
: selectedIndex == originalIndex,
|
||||||
selectedIndices.remove(answerIndex);
|
onTap: () {
|
||||||
} else {
|
setState(() {
|
||||||
selectedIndices.add(answerIndex);
|
if (isMultipleCorrect) {
|
||||||
}
|
if (selectedIndices.contains(originalIndex)) {
|
||||||
_answers[question.id] = selectedIndices.toList()..sort();
|
selectedIndices.remove(originalIndex);
|
||||||
} else {
|
} else {
|
||||||
// Toggle: if already selected, deselect; otherwise select
|
selectedIndices.add(originalIndex);
|
||||||
if (selectedIndex == answerIndex) {
|
}
|
||||||
selectedIndex = null;
|
_answers[question.id] = selectedIndices.toList()..sort();
|
||||||
_answers.remove(question.id);
|
} else {
|
||||||
} else {
|
if (selectedIndex == originalIndex) {
|
||||||
selectedIndex = answerIndex;
|
selectedIndex = null;
|
||||||
_answers[question.id] = selectedIndex;
|
_answers.remove(question.id);
|
||||||
}
|
} else {
|
||||||
}
|
selectedIndex = originalIndex;
|
||||||
// Update current question state if viewing it
|
_answers[question.id] = selectedIndex;
|
||||||
if (index == _currentQuestionIndex) {
|
}
|
||||||
if (isMultipleCorrect) {
|
}
|
||||||
_selectedAnswerIndices = selectedIndices;
|
if (index == _currentQuestionIndex) {
|
||||||
_selectedAnswerIndex = null;
|
if (isMultipleCorrect) {
|
||||||
} else {
|
_selectedAnswerIndices = selectedIndices;
|
||||||
_selectedAnswerIndex = selectedIndex;
|
_selectedAnswerIndex = null;
|
||||||
_selectedAnswerIndices.clear();
|
} else {
|
||||||
}
|
_selectedAnswerIndex = selectedIndex;
|
||||||
}
|
_selectedAnswerIndices.clear();
|
||||||
});
|
}
|
||||||
},
|
}
|
||||||
isCorrect: _deck != null && _deck!.config.immediateFeedbackEnabled &&
|
});
|
||||||
(isMultipleCorrect
|
},
|
||||||
? selectedIndices.contains(answerIndex)
|
isCorrect: _deck != null && _deck!.config.immediateFeedbackEnabled &&
|
||||||
: selectedIndex == answerIndex)
|
(isMultipleCorrect
|
||||||
? question.isCorrectAnswer(answerIndex)
|
? selectedIndices.contains(originalIndex)
|
||||||
: null,
|
: selectedIndex == originalIndex)
|
||||||
isMultipleChoice: isMultipleCorrect,
|
? question.isCorrectAnswer(originalIndex)
|
||||||
),
|
: null,
|
||||||
),
|
isMultipleChoice: isMultipleCorrect,
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}(),
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
@@ -788,11 +807,10 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
questionId: question.id,
|
questionId: question.id,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showTopSnackBar(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text('Question marked as Known'),
|
message: 'Question marked as Known',
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.check_circle),
|
icon: const Icon(Icons.check_circle),
|
||||||
@@ -810,10 +828,9 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
questionId: question.id,
|
questionId: question.id,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showTopSnackBar(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text('Question marked as Needs Practice'),
|
message: 'Question marked as Needs Practice',
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.school),
|
icon: const Icon(Icons.school),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../utils/top_snackbar.dart';
|
||||||
import 'package:practice_engine/practice_engine.dart';
|
import 'package:practice_engine/practice_engine.dart';
|
||||||
import '../services/deck_storage.dart';
|
import '../services/deck_storage.dart';
|
||||||
import '../routes.dart';
|
import '../routes.dart';
|
||||||
@@ -22,6 +23,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
late TextEditingController _timeLimitSecondsController;
|
late TextEditingController _timeLimitSecondsController;
|
||||||
late bool _immediateFeedback;
|
late bool _immediateFeedback;
|
||||||
late bool _includeKnownInAttempts;
|
late bool _includeKnownInAttempts;
|
||||||
|
late bool _shuffleAnswerOrder;
|
||||||
bool _timeLimitEnabled = false;
|
bool _timeLimitEnabled = false;
|
||||||
String? _lastDeckId;
|
String? _lastDeckId;
|
||||||
int _configHashCode = 0;
|
int _configHashCode = 0;
|
||||||
@@ -71,7 +73,8 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
);
|
);
|
||||||
_immediateFeedback = _config!.immediateFeedbackEnabled;
|
_immediateFeedback = _config!.immediateFeedbackEnabled;
|
||||||
_includeKnownInAttempts = _config!.includeKnownInAttempts;
|
_includeKnownInAttempts = _config!.includeKnownInAttempts;
|
||||||
|
_shuffleAnswerOrder = _config!.shuffleAnswerOrder;
|
||||||
|
|
||||||
// Initialize time limit controllers
|
// Initialize time limit controllers
|
||||||
_timeLimitEnabled = _config!.timeLimitSeconds != null;
|
_timeLimitEnabled = _config!.timeLimitSeconds != null;
|
||||||
final totalSeconds = _config!.timeLimitSeconds ?? 0;
|
final totalSeconds = _config!.timeLimitSeconds ?? 0;
|
||||||
@@ -192,18 +195,18 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
priorityDecreaseOnCorrect: priorityDecrease,
|
priorityDecreaseOnCorrect: priorityDecrease,
|
||||||
immediateFeedbackEnabled: _immediateFeedback,
|
immediateFeedbackEnabled: _immediateFeedback,
|
||||||
includeKnownInAttempts: _includeKnownInAttempts,
|
includeKnownInAttempts: _includeKnownInAttempts,
|
||||||
|
shuffleAnswerOrder: _shuffleAnswerOrder,
|
||||||
timeLimitSeconds: timeLimitSeconds,
|
timeLimitSeconds: timeLimitSeconds,
|
||||||
);
|
);
|
||||||
|
|
||||||
final updatedDeck = _deck!.copyWith(config: updatedConfig);
|
final updatedDeck = _deck!.copyWith(config: updatedConfig);
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showTopSnackBar(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text('Deck configuration saved successfully'),
|
message: 'Deck configuration saved successfully',
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
duration: Duration(seconds: 1),
|
duration: const Duration(seconds: 1),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Pop with the updated deck
|
// Pop with the updated deck
|
||||||
@@ -276,12 +279,11 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showTopSnackBar(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text('Deck has been reset successfully'),
|
message: 'Deck has been reset successfully',
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
duration: Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,6 +401,21 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Shuffle Answer Order Toggle
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Shuffle Answer Order'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Show answer options in random order each attempt',
|
||||||
|
),
|
||||||
|
value: _shuffleAnswerOrder,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_shuffleAnswerOrder = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Time Limit Section
|
// Time Limit Section
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
title: const Text('Enable Time Limit'),
|
title: const Text('Enable Time Limit'),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../utils/top_snackbar.dart';
|
||||||
import 'package:practice_engine/practice_engine.dart';
|
import 'package:practice_engine/practice_engine.dart';
|
||||||
import '../services/deck_storage.dart';
|
import '../services/deck_storage.dart';
|
||||||
import 'deck_edit_screen.dart';
|
import 'deck_edit_screen.dart';
|
||||||
@@ -98,12 +99,11 @@ class _DeckCreateScreenState extends State<DeckCreateScreen> {
|
|||||||
|
|
||||||
_deckStorage.saveDeckSync(newDeck);
|
_deckStorage.saveDeckSync(newDeck);
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showTopSnackBar(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text('Deck created successfully'),
|
message: 'Deck created successfully',
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
duration: Duration(seconds: 1),
|
duration: const Duration(seconds: 1),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Navigator.pop(context, newDeck);
|
Navigator.pop(context, newDeck);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../utils/top_snackbar.dart';
|
||||||
import 'package:practice_engine/practice_engine.dart';
|
import 'package:practice_engine/practice_engine.dart';
|
||||||
import '../services/deck_storage.dart';
|
import '../services/deck_storage.dart';
|
||||||
|
|
||||||
@@ -120,12 +121,11 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
|
|||||||
|
|
||||||
_deckStorage.saveDeckSync(updatedDeck);
|
_deckStorage.saveDeckSync(updatedDeck);
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showTopSnackBar(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text('Deck saved successfully'),
|
message: 'Deck saved successfully',
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
duration: Duration(seconds: 1),
|
duration: const Duration(seconds: 1),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Navigator.pop(context, updatedDeck);
|
Navigator.pop(context, updatedDeck);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:practice_engine/practice_engine.dart';
|
import 'package:practice_engine/practice_engine.dart';
|
||||||
import '../data/default_deck.dart';
|
import '../data/default_deck.dart';
|
||||||
import '../services/deck_storage.dart';
|
import '../services/deck_storage.dart';
|
||||||
|
import '../utils/top_snackbar.dart';
|
||||||
|
|
||||||
class DeckImportScreen extends StatefulWidget {
|
class DeckImportScreen extends StatefulWidget {
|
||||||
const DeckImportScreen({super.key});
|
const DeckImportScreen({super.key});
|
||||||
@@ -35,6 +37,7 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
|
|||||||
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,
|
includeKnownInAttempts: configJson['includeKnownInAttempts'] as bool? ?? false,
|
||||||
|
shuffleAnswerOrder: configJson['shuffleAnswerOrder'] as bool? ?? true,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Parse questions
|
// Parse questions
|
||||||
@@ -173,11 +176,10 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
|
|||||||
} else if (action == 'replace') {
|
} else if (action == 'replace') {
|
||||||
deckStorage.saveDeckSync(defaultDeck);
|
deckStorage.saveDeckSync(defaultDeck);
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showTopSnackBar(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text('Default deck replaced successfully'),
|
message: 'Default deck replaced successfully',
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} else if (action == 'copy') {
|
} else if (action == 'copy') {
|
||||||
// Find the next copy number
|
// Find the next copy number
|
||||||
@@ -210,22 +212,20 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
|
|||||||
);
|
);
|
||||||
deckStorage.saveDeckSync(copiedDeck);
|
deckStorage.saveDeckSync(copiedDeck);
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showTopSnackBar(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text('Default deck copied successfully'),
|
message: 'Default deck copied successfully',
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Save default deck to storage
|
// Save default deck to storage
|
||||||
deckStorage.saveDeckSync(defaultDeck);
|
deckStorage.saveDeckSync(defaultDeck);
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showTopSnackBar(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text('Default deck added successfully'),
|
message: 'Default deck added successfully',
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +245,7 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
|
|||||||
'priorityDecreaseOnCorrect': 2,
|
'priorityDecreaseOnCorrect': 2,
|
||||||
'immediateFeedbackEnabled': true,
|
'immediateFeedbackEnabled': true,
|
||||||
'includeKnownInAttempts': false,
|
'includeKnownInAttempts': false,
|
||||||
|
'shuffleAnswerOrder': true,
|
||||||
},
|
},
|
||||||
'questions': List.generate(20, (i) {
|
'questions': List.generate(20, (i) {
|
||||||
return {
|
return {
|
||||||
@@ -260,6 +261,16 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
|
|||||||
_jsonController.text = const JsonEncoder.withIndent(' ').convert(sampleJson);
|
_jsonController.text = const JsonEncoder.withIndent(' ').convert(sampleJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _copyFieldToClipboard() {
|
||||||
|
final text = _jsonController.text.trim();
|
||||||
|
if (text.isEmpty) {
|
||||||
|
showTopSnackBar(context, message: 'Nothing to copy');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Clipboard.setData(ClipboardData(text: text));
|
||||||
|
showTopSnackBar(context, message: 'JSON copied to clipboard');
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -379,10 +390,20 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
|
|||||||
'Deck JSON',
|
'Deck JSON',
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
TextButton.icon(
|
Row(
|
||||||
onPressed: _loadSampleDeck,
|
mainAxisSize: MainAxisSize.min,
|
||||||
icon: const Icon(Icons.description),
|
children: [
|
||||||
label: const Text('Load Sample'),
|
TextButton.icon(
|
||||||
|
onPressed: _loadSampleDeck,
|
||||||
|
icon: const Icon(Icons.description),
|
||||||
|
label: const Text('Load Sample'),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _copyFieldToClipboard,
|
||||||
|
icon: const Icon(Icons.copy),
|
||||||
|
tooltip: 'Copy field contents',
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -491,6 +512,7 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
|
|||||||
const Text('• priorityDecreaseOnCorrect: number'),
|
const Text('• priorityDecreaseOnCorrect: number'),
|
||||||
const Text('• immediateFeedbackEnabled: boolean'),
|
const Text('• immediateFeedbackEnabled: boolean'),
|
||||||
const Text('• includeKnownInAttempts: boolean'),
|
const Text('• includeKnownInAttempts: boolean'),
|
||||||
|
const Text('• shuffleAnswerOrder: boolean'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:practice_engine/practice_engine.dart';
|
|||||||
import '../routes.dart';
|
import '../routes.dart';
|
||||||
import '../services/deck_storage.dart';
|
import '../services/deck_storage.dart';
|
||||||
import '../data/default_deck.dart';
|
import '../data/default_deck.dart';
|
||||||
|
import '../utils/top_snackbar.dart';
|
||||||
|
|
||||||
class DeckListScreen extends StatefulWidget {
|
class DeckListScreen extends StatefulWidget {
|
||||||
const DeckListScreen({super.key});
|
const DeckListScreen({super.key});
|
||||||
@@ -73,11 +74,10 @@ class _DeckListScreenState extends State<DeckListScreen> {
|
|||||||
_deckStorage.deleteDeckSync(deck.id);
|
_deckStorage.deleteDeckSync(deck.id);
|
||||||
_loadDecks();
|
_loadDecks();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showTopSnackBar(
|
||||||
SnackBar(
|
context,
|
||||||
content: Text('${deck.title} deleted'),
|
message: '${deck.title} deleted',
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,11 +125,10 @@ class _DeckListScreenState extends State<DeckListScreen> {
|
|||||||
_loadDecks();
|
_loadDecks();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showTopSnackBar(
|
||||||
SnackBar(
|
context,
|
||||||
content: Text('${deck.title} cloned successfully'),
|
message: '${deck.title} cloned successfully',
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,11 +241,10 @@ class _DeckListScreenState extends State<DeckListScreen> {
|
|||||||
_deckStorage.saveDeckSync(defaultDeck);
|
_deckStorage.saveDeckSync(defaultDeck);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showTopSnackBar(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text('Default deck replaced successfully'),
|
message: 'Default deck replaced successfully',
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (action == 'copy') {
|
} else if (action == 'copy') {
|
||||||
@@ -281,11 +279,10 @@ class _DeckListScreenState extends State<DeckListScreen> {
|
|||||||
_deckStorage.saveDeckSync(copiedDeck);
|
_deckStorage.saveDeckSync(copiedDeck);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showTopSnackBar(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text('Default deck copied successfully'),
|
message: 'Default deck copied successfully',
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,11 +291,10 @@ class _DeckListScreenState extends State<DeckListScreen> {
|
|||||||
_deckStorage.saveDeckSync(defaultDeck);
|
_deckStorage.saveDeckSync(defaultDeck);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showTopSnackBar(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text('Default deck added successfully'),
|
message: 'Default deck added successfully',
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -495,7 +491,7 @@ class _DeckListScreenState extends State<DeckListScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: _StatChip(
|
child: _StatChip(
|
||||||
icon: Icons.trending_up,
|
icon: Icons.trending_up,
|
||||||
label: '${deck.practicePercentage.toStringAsFixed(0)}%',
|
label: '${deck.progressPercentage.toStringAsFixed(0)}%',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 '../services/deck_storage.dart';
|
import '../services/deck_storage.dart';
|
||||||
|
import '../utils/top_snackbar.dart';
|
||||||
|
|
||||||
class DeckOverviewScreen extends StatefulWidget {
|
class DeckOverviewScreen extends StatefulWidget {
|
||||||
const DeckOverviewScreen({super.key});
|
const DeckOverviewScreen({super.key});
|
||||||
@@ -252,14 +253,13 @@ class _DeckOverviewScreenState extends State<DeckOverviewScreen> {
|
|||||||
|
|
||||||
// Show confirmation message
|
// Show confirmation message
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showTopSnackBar(
|
||||||
SnackBar(
|
context,
|
||||||
content: Text(wasReset
|
message: wasReset
|
||||||
? 'Deck has been reset successfully'
|
? 'Deck has been reset successfully'
|
||||||
: 'Deck settings have been updated'),
|
: 'Deck settings have been updated',
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: Colors.green,
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -467,7 +467,7 @@ class _DeckOverviewScreenState extends State<DeckOverviewScreen> {
|
|||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
value: _deck!.practicePercentage / 100,
|
value: _deck!.progressPercentage / 100,
|
||||||
strokeWidth: 6,
|
strokeWidth: 6,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -477,7 +477,7 @@ class _DeckOverviewScreenState extends State<DeckOverviewScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'${_deck!.practicePercentage.toStringAsFixed(1)}%',
|
'${_deck!.progressPercentage.toStringAsFixed(1)}%',
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ class DeckStorage {
|
|||||||
'priorityDecreaseOnCorrect': deck.config.priorityDecreaseOnCorrect,
|
'priorityDecreaseOnCorrect': deck.config.priorityDecreaseOnCorrect,
|
||||||
'immediateFeedbackEnabled': deck.config.immediateFeedbackEnabled,
|
'immediateFeedbackEnabled': deck.config.immediateFeedbackEnabled,
|
||||||
'includeKnownInAttempts': deck.config.includeKnownInAttempts,
|
'includeKnownInAttempts': deck.config.includeKnownInAttempts,
|
||||||
|
'shuffleAnswerOrder': deck.config.shuffleAnswerOrder,
|
||||||
'timeLimitSeconds': deck.config.timeLimitSeconds,
|
'timeLimitSeconds': deck.config.timeLimitSeconds,
|
||||||
},
|
},
|
||||||
'questions': deck.questions.map((q) => {
|
'questions': deck.questions.map((q) => {
|
||||||
@@ -105,6 +106,7 @@ class DeckStorage {
|
|||||||
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,
|
includeKnownInAttempts: configJson['includeKnownInAttempts'] as bool? ?? false,
|
||||||
|
shuffleAnswerOrder: configJson['shuffleAnswerOrder'] as bool? ?? true,
|
||||||
timeLimitSeconds: configJson['timeLimitSeconds'] as int?,
|
timeLimitSeconds: configJson['timeLimitSeconds'] as int?,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Shows a snackbar-style message at the top of the screen (for success/neutral feedback).
|
||||||
|
void showTopSnackBar(
|
||||||
|
BuildContext context, {
|
||||||
|
required String message,
|
||||||
|
Color? backgroundColor,
|
||||||
|
Duration duration = const Duration(seconds: 2),
|
||||||
|
}) {
|
||||||
|
final overlay = Overlay.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final color = backgroundColor ?? theme.colorScheme.surfaceContainerHighest;
|
||||||
|
final textColor = backgroundColor != null ? Colors.white : theme.colorScheme.onSurface;
|
||||||
|
|
||||||
|
late OverlayEntry entry;
|
||||||
|
entry = OverlayEntry(
|
||||||
|
builder: (context) => Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
child: Material(
|
||||||
|
color: color,
|
||||||
|
elevation: 4,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(color: textColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
overlay.insert(entry);
|
||||||
|
Future.delayed(duration, () {
|
||||||
|
entry.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -68,9 +68,15 @@ class AttemptService {
|
|||||||
final answerResults = <AnswerResult>[];
|
final answerResults = <AnswerResult>[];
|
||||||
final updatedQuestions = <Question>[];
|
final updatedQuestions = <Question>[];
|
||||||
|
|
||||||
// Process each question in the attempt
|
// Process each question in the attempt.
|
||||||
for (final question in attempt.questions) {
|
// Use the deck's current question state (not the attempt snapshot) so that
|
||||||
final userAnswer = answers[question.id];
|
// manual "mark as known" during the attempt is preserved when applying results.
|
||||||
|
for (final questionInAttempt in attempt.questions) {
|
||||||
|
final currentQuestion = deck.questions
|
||||||
|
.where((q) => q.id == questionInAttempt.id)
|
||||||
|
.firstOrNull ?? questionInAttempt;
|
||||||
|
|
||||||
|
final userAnswer = answers[currentQuestion.id];
|
||||||
if (userAnswer == null) {
|
if (userAnswer == null) {
|
||||||
// Question not answered, treat as incorrect
|
// Question not answered, treat as incorrect
|
||||||
continue;
|
continue;
|
||||||
@@ -89,28 +95,28 @@ class AttemptService {
|
|||||||
|
|
||||||
// Check if answer is correct
|
// Check if answer is correct
|
||||||
// For multiple correct answers: user must select all correct answers and no incorrect ones
|
// For multiple correct answers: user must select all correct answers and no incorrect ones
|
||||||
final correctIndices = question.correctIndices;
|
final correctIndices = currentQuestion.correctIndices;
|
||||||
final userSet = userAnswerIndices.toSet();
|
final userSet = userAnswerIndices.toSet();
|
||||||
final correctSet = correctIndices.toSet();
|
final correctSet = correctIndices.toSet();
|
||||||
final isCorrect = userSet.length == correctSet.length &&
|
final isCorrect = userSet.length == correctSet.length &&
|
||||||
userSet.every((idx) => correctSet.contains(idx));
|
userSet.every((idx) => correctSet.contains(idx));
|
||||||
final userMarkedNeedsPractice = overrides[question.id] ?? false;
|
final userMarkedNeedsPractice = overrides[currentQuestion.id] ?? false;
|
||||||
|
|
||||||
// Determine status change
|
// Determine status change (from current deck state)
|
||||||
final oldIsKnown = question.isKnown;
|
final oldIsKnown = currentQuestion.isKnown;
|
||||||
final oldStreak = question.consecutiveCorrect;
|
final oldStreak = currentQuestion.consecutiveCorrect;
|
||||||
|
|
||||||
// Update question
|
// Update question from current deck state so manual marks are preserved
|
||||||
final updated = userMarkedNeedsPractice
|
final updated = userMarkedNeedsPractice
|
||||||
? DeckService.updateQuestionWithManualOverride(
|
? DeckService.updateQuestionWithManualOverride(
|
||||||
question: question,
|
question: currentQuestion,
|
||||||
isCorrect: isCorrect,
|
isCorrect: isCorrect,
|
||||||
userMarkedNeedsPractice: true,
|
userMarkedNeedsPractice: true,
|
||||||
config: deck.config,
|
config: deck.config,
|
||||||
currentAttemptIndex: deck.currentAttemptIndex,
|
currentAttemptIndex: deck.currentAttemptIndex,
|
||||||
)
|
)
|
||||||
: DeckService.updateQuestionAfterAnswer(
|
: DeckService.updateQuestionAfterAnswer(
|
||||||
question: question,
|
question: currentQuestion,
|
||||||
isCorrect: isCorrect,
|
isCorrect: isCorrect,
|
||||||
config: deck.config,
|
config: deck.config,
|
||||||
currentAttemptIndex: deck.currentAttemptIndex,
|
currentAttemptIndex: deck.currentAttemptIndex,
|
||||||
|
|||||||
@@ -76,6 +76,20 @@ class Deck {
|
|||||||
return (knownCount / questions.length) * 100.0;
|
return (knownCount / questions.length) * 100.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Progress percentage including partial credit: each question contributes
|
||||||
|
/// 1.0 if known, otherwise (consecutiveCorrect / requiredConsecutiveCorrect)
|
||||||
|
/// capped at 1.0. Averaged over all questions * 100.
|
||||||
|
double get progressPercentage {
|
||||||
|
if (questions.isEmpty) return 0.0;
|
||||||
|
final required = config.requiredConsecutiveCorrect;
|
||||||
|
final sum = questions.fold<double>(0.0, (sum, q) {
|
||||||
|
if (q.isKnown) return sum + 1.0;
|
||||||
|
final partial = (q.consecutiveCorrect / required).clamp(0.0, 1.0);
|
||||||
|
return sum + partial;
|
||||||
|
});
|
||||||
|
return (sum / questions.length) * 100.0;
|
||||||
|
}
|
||||||
|
|
||||||
/// Number of completed attempts.
|
/// Number of completed attempts.
|
||||||
int get attemptCount => attemptHistory.length;
|
int get attemptCount => attemptHistory.length;
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ class DeckConfig {
|
|||||||
/// If false, known questions will be excluded from attempts.
|
/// If false, known questions will be excluded from attempts.
|
||||||
final bool includeKnownInAttempts;
|
final bool includeKnownInAttempts;
|
||||||
|
|
||||||
|
/// Whether to shuffle answer order for each question in an attempt.
|
||||||
|
/// When true, answer options appear in random order each attempt.
|
||||||
|
final bool shuffleAnswerOrder;
|
||||||
|
|
||||||
/// Optional time limit for attempts in seconds.
|
/// Optional time limit for attempts in seconds.
|
||||||
/// If null, no time limit is enforced.
|
/// If null, no time limit is enforced.
|
||||||
final int? timeLimitSeconds;
|
final int? timeLimitSeconds;
|
||||||
@@ -30,6 +34,7 @@ class DeckConfig {
|
|||||||
this.priorityDecreaseOnCorrect = 2,
|
this.priorityDecreaseOnCorrect = 2,
|
||||||
this.immediateFeedbackEnabled = true,
|
this.immediateFeedbackEnabled = true,
|
||||||
this.includeKnownInAttempts = false,
|
this.includeKnownInAttempts = false,
|
||||||
|
this.shuffleAnswerOrder = true,
|
||||||
this.timeLimitSeconds,
|
this.timeLimitSeconds,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,6 +46,7 @@ class DeckConfig {
|
|||||||
int? priorityDecreaseOnCorrect,
|
int? priorityDecreaseOnCorrect,
|
||||||
bool? immediateFeedbackEnabled,
|
bool? immediateFeedbackEnabled,
|
||||||
bool? includeKnownInAttempts,
|
bool? includeKnownInAttempts,
|
||||||
|
bool? shuffleAnswerOrder,
|
||||||
int? timeLimitSeconds,
|
int? timeLimitSeconds,
|
||||||
}) {
|
}) {
|
||||||
return DeckConfig(
|
return DeckConfig(
|
||||||
@@ -55,6 +61,7 @@ class DeckConfig {
|
|||||||
immediateFeedbackEnabled ?? this.immediateFeedbackEnabled,
|
immediateFeedbackEnabled ?? this.immediateFeedbackEnabled,
|
||||||
includeKnownInAttempts:
|
includeKnownInAttempts:
|
||||||
includeKnownInAttempts ?? this.includeKnownInAttempts,
|
includeKnownInAttempts ?? this.includeKnownInAttempts,
|
||||||
|
shuffleAnswerOrder: shuffleAnswerOrder ?? this.shuffleAnswerOrder,
|
||||||
timeLimitSeconds: timeLimitSeconds ?? this.timeLimitSeconds,
|
timeLimitSeconds: timeLimitSeconds ?? this.timeLimitSeconds,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -70,6 +77,7 @@ class DeckConfig {
|
|||||||
priorityDecreaseOnCorrect == other.priorityDecreaseOnCorrect &&
|
priorityDecreaseOnCorrect == other.priorityDecreaseOnCorrect &&
|
||||||
immediateFeedbackEnabled == other.immediateFeedbackEnabled &&
|
immediateFeedbackEnabled == other.immediateFeedbackEnabled &&
|
||||||
includeKnownInAttempts == other.includeKnownInAttempts &&
|
includeKnownInAttempts == other.includeKnownInAttempts &&
|
||||||
|
shuffleAnswerOrder == other.shuffleAnswerOrder &&
|
||||||
timeLimitSeconds == other.timeLimitSeconds;
|
timeLimitSeconds == other.timeLimitSeconds;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -80,6 +88,7 @@ class DeckConfig {
|
|||||||
priorityDecreaseOnCorrect.hashCode ^
|
priorityDecreaseOnCorrect.hashCode ^
|
||||||
immediateFeedbackEnabled.hashCode ^
|
immediateFeedbackEnabled.hashCode ^
|
||||||
includeKnownInAttempts.hashCode ^
|
includeKnownInAttempts.hashCode ^
|
||||||
|
shuffleAnswerOrder.hashCode ^
|
||||||
(timeLimitSeconds?.hashCode ?? 0);
|
(timeLimitSeconds?.hashCode ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:practice_engine/models/deck.dart';
|
|||||||
import 'package:practice_engine/models/deck_config.dart';
|
import 'package:practice_engine/models/deck_config.dart';
|
||||||
import 'package:practice_engine/models/question.dart';
|
import 'package:practice_engine/models/question.dart';
|
||||||
import 'package:practice_engine/logic/attempt_service.dart';
|
import 'package:practice_engine/logic/attempt_service.dart';
|
||||||
|
import 'package:practice_engine/logic/deck_service.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('Attempt Flow', () {
|
group('Attempt Flow', () {
|
||||||
@@ -152,6 +153,51 @@ void main() {
|
|||||||
expect(updated.consecutiveCorrect, equals(3));
|
expect(updated.consecutiveCorrect, equals(3));
|
||||||
expect(updated.isKnown, equals(true));
|
expect(updated.isKnown, equals(true));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('processAttempt preserves manual mark as known when using current deck state', () {
|
||||||
|
// One question, not known, no streak
|
||||||
|
final question = Question(
|
||||||
|
id: 'q1',
|
||||||
|
prompt: 'Test',
|
||||||
|
answers: ['A', 'B'],
|
||||||
|
correctAnswerIndices: [0],
|
||||||
|
consecutiveCorrect: 0,
|
||||||
|
isKnown: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final testDeck = Deck(
|
||||||
|
id: 'deck1',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
questions: [question],
|
||||||
|
config: const DeckConfig(requiredConsecutiveCorrect: 3),
|
||||||
|
);
|
||||||
|
|
||||||
|
final attempt = attemptService.createAttempt(
|
||||||
|
deck: testDeck,
|
||||||
|
attemptSize: 1,
|
||||||
|
);
|
||||||
|
expect(attempt.questions.single.isKnown, isFalse);
|
||||||
|
|
||||||
|
// User marks as known during the attempt (as in the app)
|
||||||
|
final deckWithManualKnown = DeckService.markQuestionAsKnown(
|
||||||
|
deck: testDeck,
|
||||||
|
questionId: question.id,
|
||||||
|
);
|
||||||
|
expect(deckWithManualKnown.questions.single.isKnown, isTrue);
|
||||||
|
|
||||||
|
// Complete attempt with correct answer, passing deck that has manual known
|
||||||
|
final answers = {question.id: question.correctIndices.first};
|
||||||
|
final result = attemptService.processAttempt(
|
||||||
|
deck: deckWithManualKnown,
|
||||||
|
attempt: attempt,
|
||||||
|
answers: answers,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Manual "known" must be preserved in the result
|
||||||
|
final updated = result.updatedDeck.questions.first;
|
||||||
|
expect(updated.isKnown, isTrue, reason: 'Manual mark as known must count towards known after attempt');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,81 @@ void main() {
|
|||||||
expect(deck.practicePercentage, equals(100.0));
|
expect(deck.practicePercentage, equals(100.0));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('progressPercentage is 0 for empty deck', () {
|
||||||
|
final deck = Deck(
|
||||||
|
id: 'deck1',
|
||||||
|
title: 'Empty Deck',
|
||||||
|
description: 'No questions',
|
||||||
|
questions: [],
|
||||||
|
config: defaultConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(deck.progressPercentage, equals(0.0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('progressPercentage equals practicePercentage when no partial streak', () {
|
||||||
|
final deck = Deck(
|
||||||
|
id: 'deck1',
|
||||||
|
title: 'Math Deck',
|
||||||
|
description: 'Basic math',
|
||||||
|
questions: sampleQuestions,
|
||||||
|
config: defaultConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(deck.progressPercentage, closeTo(deck.practicePercentage, 0.01));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('progressPercentage is 100 when all questions are known', () {
|
||||||
|
final allKnown = sampleQuestions.map((q) => q.copyWith(isKnown: true)).toList();
|
||||||
|
final deck = Deck(
|
||||||
|
id: 'deck1',
|
||||||
|
title: 'All Known',
|
||||||
|
description: 'All known',
|
||||||
|
questions: allKnown,
|
||||||
|
config: defaultConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(deck.progressPercentage, equals(100.0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('progressPercentage includes partial credit from consecutiveCorrect', () {
|
||||||
|
// requiredConsecutiveCorrect is 3 (default). 3 questions: none known, each at 2/3
|
||||||
|
final withPartial = [
|
||||||
|
Question(id: 'q1', prompt: 'A', answers: ['x'], correctAnswerIndices: [0], consecutiveCorrect: 2, isKnown: false),
|
||||||
|
Question(id: 'q2', prompt: 'B', answers: ['x'], correctAnswerIndices: [0], consecutiveCorrect: 2, isKnown: false),
|
||||||
|
Question(id: 'q3', prompt: 'C', answers: ['x'], correctAnswerIndices: [0], consecutiveCorrect: 2, isKnown: false),
|
||||||
|
];
|
||||||
|
final deck = Deck(
|
||||||
|
id: 'deck1',
|
||||||
|
title: 'Partial',
|
||||||
|
description: 'Partial progress',
|
||||||
|
questions: withPartial,
|
||||||
|
config: defaultConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(deck.practicePercentage, equals(0.0));
|
||||||
|
expect(deck.progressPercentage, closeTo(66.67, 0.01)); // 3 * (2/3) / 3 * 100
|
||||||
|
});
|
||||||
|
|
||||||
|
test('progressPercentage mixes known and partial correctly', () {
|
||||||
|
final config = DeckConfig(requiredConsecutiveCorrect: 3);
|
||||||
|
final questions = [
|
||||||
|
Question(id: 'q1', prompt: 'A', answers: ['x'], correctAnswerIndices: [0], isKnown: true),
|
||||||
|
Question(id: 'q2', prompt: 'B', answers: ['x'], correctAnswerIndices: [0], consecutiveCorrect: 2, isKnown: false),
|
||||||
|
Question(id: 'q3', prompt: 'C', answers: ['x'], correctAnswerIndices: [0], consecutiveCorrect: 0, isKnown: false),
|
||||||
|
];
|
||||||
|
final deck = Deck(
|
||||||
|
id: 'deck1',
|
||||||
|
title: 'Mixed',
|
||||||
|
description: 'Mixed',
|
||||||
|
questions: questions,
|
||||||
|
config: config,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1.0 + 2/3 + 0 = 1.667; 1.667/3*100 ≈ 55.56
|
||||||
|
expect(deck.progressPercentage, closeTo(55.56, 0.01));
|
||||||
|
});
|
||||||
|
|
||||||
test('copyWith creates new deck with updated fields', () {
|
test('copyWith creates new deck with updated fields', () {
|
||||||
final deck = Deck(
|
final deck = Deck(
|
||||||
id: 'deck1',
|
id: 'deck1',
|
||||||
|
|||||||
Reference in New Issue
Block a user