shufle order

master
gitea 1 month ago
parent 2ea573642d
commit 7ab2eb2ba4

@ -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,37 +724,39 @@ 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( ...() {
final order = _answerOrderPerQuestion[question.id] ??
List.generate(question.answers.length, (i) => i);
return List.generate(
question.answers.length, question.answers.length,
(answerIndex) => Padding( (displayIndex) {
final originalIndex = order[displayIndex];
return Padding(
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: 8),
child: AnswerOption( child: AnswerOption(
text: question.answers[answerIndex], text: question.answers[originalIndex],
isSelected: isMultipleCorrect isSelected: isMultipleCorrect
? selectedIndices.contains(answerIndex) ? selectedIndices.contains(originalIndex)
: selectedIndex == answerIndex, : selectedIndex == originalIndex,
onTap: () { onTap: () {
// Update selection for this question
setState(() { setState(() {
if (isMultipleCorrect) { if (isMultipleCorrect) {
if (selectedIndices.contains(answerIndex)) { if (selectedIndices.contains(originalIndex)) {
selectedIndices.remove(answerIndex); selectedIndices.remove(originalIndex);
} else { } else {
selectedIndices.add(answerIndex); selectedIndices.add(originalIndex);
} }
_answers[question.id] = selectedIndices.toList()..sort(); _answers[question.id] = selectedIndices.toList()..sort();
} else { } else {
// Toggle: if already selected, deselect; otherwise select if (selectedIndex == originalIndex) {
if (selectedIndex == answerIndex) {
selectedIndex = null; selectedIndex = null;
_answers.remove(question.id); _answers.remove(question.id);
} else { } else {
selectedIndex = answerIndex; selectedIndex = originalIndex;
_answers[question.id] = selectedIndex; _answers[question.id] = selectedIndex;
} }
} }
// Update current question state if viewing it
if (index == _currentQuestionIndex) { if (index == _currentQuestionIndex) {
if (isMultipleCorrect) { if (isMultipleCorrect) {
_selectedAnswerIndices = selectedIndices; _selectedAnswerIndices = selectedIndices;
@ -753,14 +770,16 @@ class _AttemptScreenState extends State<AttemptScreen> {
}, },
isCorrect: _deck != null && _deck!.config.immediateFeedbackEnabled && isCorrect: _deck != null && _deck!.config.immediateFeedbackEnabled &&
(isMultipleCorrect (isMultipleCorrect
? selectedIndices.contains(answerIndex) ? selectedIndices.contains(originalIndex)
: selectedIndex == answerIndex) : selectedIndex == originalIndex)
? question.isCorrectAnswer(answerIndex) ? question.isCorrectAnswer(originalIndex)
: null, : null,
isMultipleChoice: isMultipleCorrect, 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,6 +73,7 @@ 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;
@ -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,11 +390,21 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
'Deck JSON', 'Deck JSON',
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton.icon( TextButton.icon(
onPressed: _loadSampleDeck, onPressed: _loadSampleDeck,
icon: const Icon(Icons.description), icon: const Icon(Icons.description),
label: const Text('Load Sample'), label: const Text('Load Sample'),
), ),
IconButton(
onPressed: _copyFieldToClipboard,
icon: const Icon(Icons.copy),
tooltip: 'Copy field contents',
),
],
),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@ -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',

Loading…
Cancel
Save

Powered by TurnKey Linux.