improvements in editing questions

master
gitea 4 weeks ago
parent f3f44c8c69
commit 1ae9413c71

@ -15,6 +15,7 @@ class AttemptResultScreen extends StatefulWidget {
class _AttemptResultScreenState extends State<AttemptResultScreen> {
Deck? _deck;
AttemptResult? _result;
Attempt? _completedAttempt;
final DeckStorage _deckStorage = DeckStorage();
@override
@ -30,6 +31,7 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
final args = ModalRoute.of(context)?.settings.arguments as Map<String, dynamic>?;
_deck = args?['deck'] as Deck? ?? _createSampleDeck();
_result = args?['result'] as AttemptResult? ?? _createSampleResult();
_completedAttempt = args?['attempt'] as Attempt?;
}
}
@ -63,7 +65,16 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
void _repeatSameAttempt() {
if (_deck == null) return;
Navigator.pushReplacementNamed(context, Routes.attempt, arguments: _deck);
// Pass the completed attempt so the attempt screen uses the same questions in the same order
if (_completedAttempt != null && _completedAttempt!.questions.isNotEmpty) {
Navigator.pushReplacementNamed(
context,
Routes.attempt,
arguments: {'deck': _deck, 'repeatAttempt': _completedAttempt},
);
} else {
Navigator.pushReplacementNamed(context, Routes.attempt, arguments: _deck);
}
}
void _newAttempt() {
@ -75,11 +86,12 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
if (_deck == null) return;
// Save the updated deck to storage
_deckStorage.saveDeckSync(_deck!);
// Navigate back to deck list
Navigator.pushNamedAndRemoveUntil(
// Navigate back to this deck's overview (decks screen), not the home deck list
Navigator.popUntil(context, (route) => route.settings.name == Routes.deckOverview);
Navigator.pushReplacementNamed(
context,
Routes.deckList,
(route) => false,
Routes.deckOverview,
arguments: _deck,
);
}
@ -273,7 +285,9 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
StatusChip(statusChange: answerResult.statusChange),
const Spacer(),
Text(
'Your answer${answerResult.userAnswerIndices.length > 1 ? 's' : ''}: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
answerResult.userAnswerIndices.isEmpty
? 'No answer'
: 'Your answer${answerResult.userAnswerIndices.length > 1 ? 's' : ''}: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
@ -332,7 +346,9 @@ class _AttemptResultScreenState extends State<AttemptResultScreen> {
subtitle: Text(
answerResult.isCorrect
? 'Correct'
: 'Incorrect - Selected: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
: answerResult.userAnswerIndices.isEmpty
? 'Incorrect - No answer'
: 'Incorrect - Selected: ${answerResult.userAnswerIndices.map((idx) => answerResult.question.answers[idx]).join(', ')}',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,

@ -101,6 +101,8 @@ class _AttemptScreenState extends State<AttemptScreen> {
bool? includeKnown;
bool? resumeAttempt;
final repeatAttempt = args is Map<String, dynamic> ? args['repeatAttempt'] as Attempt? : null;
if (args is Map<String, dynamic>) {
_deck = args['deck'] as Deck? ?? _createSampleDeck();
includeKnown = args['includeKnown'] as bool?;
@ -112,9 +114,36 @@ class _AttemptScreenState extends State<AttemptScreen> {
_deck = _createSampleDeck();
resumeAttempt = false;
}
// Check if we should resume an incomplete attempt
if (resumeAttempt == true && _deck!.incompleteAttempt != null) {
// Check if we should repeat the exact same attempt (same questions, same order)
if (repeatAttempt != null && repeatAttempt.questions.isNotEmpty) {
final orderedIds = repeatAttempt.questions.map((q) => q.id).toList();
final questions = <Question>[];
for (final id in orderedIds) {
final q = _deck!.questions.where((q) => q.id == id).firstOrNull;
if (q != null) questions.add(q);
}
if (questions.isNotEmpty) {
_attempt = Attempt(
id: 'repeat-${DateTime.now().millisecondsSinceEpoch}',
questions: questions,
startTime: DateTime.now().millisecondsSinceEpoch,
);
// Same as new attempt: randomize answer order per question when 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;
}
}
}
// If not repeating, check if we should resume an incomplete attempt
if (_attempt == null && resumeAttempt == true && _deck!.incompleteAttempt != null) {
final incomplete = _deck!.incompleteAttempt!;
_attempt = incomplete.toAttempt(_deck!.questions);
_currentQuestionIndex = incomplete.currentQuestionIndex;
@ -133,7 +162,10 @@ class _AttemptScreenState extends State<AttemptScreen> {
_pageController.jumpToPage(_currentQuestionIndex);
}
});
} else {
}
// Otherwise create a new attempt
if (_attempt == null) {
_attempt = _attemptService!.createAttempt(
deck: _deck!,
includeKnown: includeKnown,

@ -1,4 +1,7 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/top_snackbar.dart';
import 'package:practice_engine/practice_engine.dart';
import '../services/deck_storage.dart';
@ -135,8 +138,10 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
super.dispose();
}
void _save() {
if (_deck == null || _config == null) return;
/// Builds config from current form state, validates, persists if valid.
/// Returns true if saved, false if validation failed.
bool _applyAndPersist() {
if (_deck == null || _config == null) return false;
final consecutive = int.tryParse(_consecutiveController.text);
final attemptSize = int.tryParse(_attemptSizeController.text);
@ -153,7 +158,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
backgroundColor: Colors.red,
),
);
return;
return false;
}
if (consecutive < 1 ||
@ -166,7 +171,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
backgroundColor: Colors.red,
),
);
return;
return false;
}
// Calculate time limit in seconds
@ -176,7 +181,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
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 {
@ -186,7 +191,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
backgroundColor: Colors.red,
),
);
return;
return false;
}
}
@ -203,21 +208,78 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
);
final updatedDeck = _deck!.copyWith(config: updatedConfig);
final deckStorage = DeckStorage();
deckStorage.saveDeckSync(updatedDeck);
// Show success message
showTopSnackBar(
context,
message: 'Deck configuration saved successfully',
backgroundColor: Colors.green,
duration: const Duration(seconds: 1),
);
setState(() {
_deck = updatedDeck;
_config = updatedConfig;
_configHashCode = updatedConfig.hashCode;
});
return true;
}
// Pop with the updated deck
Navigator.pop(context, updatedDeck);
Map<String, dynamic> _configToJsonMap() {
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;
timeLimitSeconds = hours * 3600 + minutes * 60 + seconds;
if (timeLimitSeconds == 0) timeLimitSeconds = null;
}
return {
'requiredConsecutiveCorrect': int.tryParse(_consecutiveController.text) ?? _config!.requiredConsecutiveCorrect,
'defaultAttemptSize': int.tryParse(_attemptSizeController.text) ?? _config!.defaultAttemptSize,
'priorityIncreaseOnIncorrect': int.tryParse(_priorityIncreaseController.text) ?? _config!.priorityIncreaseOnIncorrect,
'priorityDecreaseOnCorrect': int.tryParse(_priorityDecreaseController.text) ?? _config!.priorityDecreaseOnCorrect,
'immediateFeedbackEnabled': _immediateFeedback,
'includeKnownInAttempts': _includeKnownInAttempts,
'shuffleAnswerOrder': _shuffleAnswerOrder,
'excludeFlaggedQuestions': _excludeFlaggedQuestions,
if (timeLimitSeconds != null) 'timeLimitSeconds': timeLimitSeconds,
};
}
void _cancel() {
Navigator.pop(context);
void _showAsJson() {
if (_deck == null || _config == null) return;
final map = _configToJsonMap();
final jsonString = const JsonEncoder.withIndent(' ').convert(map);
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Attempt Settings as JSON'),
content: SingleChildScrollView(
child: SelectableText(
jsonString,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
FilledButton.icon(
onPressed: () {
Clipboard.setData(ClipboardData(text: jsonString));
Navigator.pop(context);
showTopSnackBar(
context,
message: 'JSON copied to clipboard',
backgroundColor: Colors.green,
duration: const Duration(seconds: 1),
);
},
icon: const Icon(Icons.copy, size: 20),
label: const Text('Copy'),
),
],
),
);
}
void _editDeck() {
@ -307,7 +369,18 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
return Scaffold(
appBar: AppBar(
title: const Text('Deck Configuration'),
title: const Text('Attempt Settings'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context, _deck),
),
actions: [
IconButton(
icon: const Icon(Icons.code),
onPressed: _showAsJson,
tooltip: 'Show as JSON',
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
@ -335,6 +408,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onEditingComplete: _applyAndPersist,
),
const SizedBox(height: 16),
@ -347,6 +421,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onEditingComplete: _applyAndPersist,
),
const SizedBox(height: 16),
@ -359,6 +434,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onEditingComplete: _applyAndPersist,
),
const SizedBox(height: 16),
@ -371,6 +447,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onEditingComplete: _applyAndPersist,
),
const SizedBox(height: 16),
@ -385,6 +462,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
setState(() {
_immediateFeedback = value;
});
_applyAndPersist();
},
),
const SizedBox(height: 16),
@ -400,6 +478,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
setState(() {
_includeKnownInAttempts = value;
});
_applyAndPersist();
},
),
const SizedBox(height: 16),
@ -415,6 +494,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
setState(() {
_shuffleAnswerOrder = value;
});
_applyAndPersist();
},
),
const SizedBox(height: 16),
@ -430,6 +510,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
setState(() {
_excludeFlaggedQuestions = value;
});
_applyAndPersist();
},
),
const SizedBox(height: 16),
@ -450,6 +531,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
_timeLimitSecondsController.clear();
}
});
_applyAndPersist();
},
),
if (_timeLimitEnabled) ...[
@ -464,6 +546,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onEditingComplete: _applyAndPersist,
),
),
const SizedBox(width: 8),
@ -475,6 +558,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onEditingComplete: _applyAndPersist,
),
),
const SizedBox(width: 8),
@ -486,6 +570,7 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onEditingComplete: _applyAndPersist,
),
),
],
@ -579,26 +664,6 @@ class _DeckConfigScreenState extends State<DeckConfigScreen> {
),
),
),
const SizedBox(height: 24),
// Action Buttons
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _cancel,
child: const Text('Cancel'),
),
),
const SizedBox(width: 16),
Expanded(
child: FilledButton(
onPressed: _save,
child: const Text('Save'),
),
),
],
),
],
),
),

@ -16,6 +16,8 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
late TextEditingController _descriptionController;
final List<QuestionEditor> _questionEditors = [];
final DeckStorage _deckStorage = DeckStorage();
final ScrollController _scrollController = ScrollController();
int? _focusNewQuestionIndex;
@override
void initState() {
@ -48,6 +50,7 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
@override
void dispose() {
_scrollController.dispose();
_titleController.dispose();
_descriptionController.dispose();
for (final editor in _questionEditors) {
@ -59,6 +62,21 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
void _addQuestion() {
setState(() {
_questionEditors.add(QuestionEditor.empty());
_focusNewQuestionIndex = _questionEditors.length - 1;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final pos = _scrollController.position;
if (pos.maxScrollExtent > pos.pixels) {
_scrollController.animateTo(
pos.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
Future.microtask(() {
if (mounted) setState(() => _focusNewQuestionIndex = null);
});
});
}
@ -156,6 +174,7 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
],
),
body: SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -197,6 +216,7 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
onDelete: () => _removeQuestion(index),
onUnflag: null,
onChanged: () => setState(() {}),
requestFocusOnPrompt: _focusNewQuestionIndex == index,
);
}),
@ -243,6 +263,7 @@ class QuestionEditorCard extends StatefulWidget {
final VoidCallback? onDelete;
final VoidCallback? onUnflag;
final VoidCallback onChanged;
final bool requestFocusOnPrompt;
const QuestionEditorCard({
super.key,
@ -251,6 +272,7 @@ class QuestionEditorCard extends StatefulWidget {
this.onDelete,
this.onUnflag,
required this.onChanged,
this.requestFocusOnPrompt = false,
});
@override
@ -258,8 +280,31 @@ class QuestionEditorCard extends StatefulWidget {
}
class _QuestionEditorCardState extends State<QuestionEditorCard> {
final FocusNode _promptFocusNode = FocusNode();
@override
void dispose() {
_promptFocusNode.dispose();
super.dispose();
}
@override
void didUpdateWidget(QuestionEditorCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.requestFocusOnPrompt && !oldWidget.requestFocusOnPrompt) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _promptFocusNode.requestFocus();
});
}
}
@override
Widget build(BuildContext context) {
if (widget.requestFocusOnPrompt) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _promptFocusNode.requestFocus();
});
}
return Card(
margin: const EdgeInsets.only(bottom: 16),
child: Padding(
@ -297,6 +342,7 @@ class _QuestionEditorCardState extends State<QuestionEditorCard> {
// Question Prompt
TextField(
controller: widget.editor.promptController,
focusNode: _promptFocusNode,
decoration: const InputDecoration(
labelText: 'Question',
border: OutlineInputBorder(),

@ -80,29 +80,32 @@ class AttemptService {
.firstOrNull ?? questionInAttempt;
final userAnswer = answers[currentQuestion.id];
if (userAnswer == null) {
// Question not answered, treat as incorrect
continue;
}
// Handle both single answer (int) and multiple answers (List<int>)
// Not answering or invalid format: treat same as incorrect
final List<int> userAnswerIndices;
if (userAnswer is int) {
final bool isCorrect;
if (userAnswer == null) {
userAnswerIndices = [];
isCorrect = false;
} else if (userAnswer is int) {
userAnswerIndices = [userAnswer];
final correctIndices = currentQuestion.correctIndices;
final userSet = userAnswerIndices.toSet();
final correctSet = correctIndices.toSet();
isCorrect = userSet.length == correctSet.length &&
userSet.every((idx) => correctSet.contains(idx));
} else if (userAnswer is List<int>) {
userAnswerIndices = userAnswer;
final correctIndices = currentQuestion.correctIndices;
final userSet = userAnswerIndices.toSet();
final correctSet = correctIndices.toSet();
isCorrect = userSet.length == correctSet.length &&
userSet.every((idx) => correctSet.contains(idx));
} else {
// Invalid format, treat as incorrect
continue;
userAnswerIndices = [];
isCorrect = false;
}
// Check if answer is correct
// For multiple correct answers: user must select all correct answers and no incorrect ones
final correctIndices = currentQuestion.correctIndices;
final userSet = userAnswerIndices.toSet();
final correctSet = correctIndices.toSet();
final isCorrect = userSet.length == correctSet.length &&
userSet.every((idx) => correctSet.contains(idx));
final userMarkedNeedsPractice = overrides[currentQuestion.id] ?? false;
// Determine status change (from current deck state)

Loading…
Cancel
Save

Powered by TurnKey Linux.