flagging
This commit is contained in:
@@ -572,14 +572,44 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
||||
appBar: AppBar(
|
||||
title: Text('Attempt - ${_deck!.title}'),
|
||||
actions: [
|
||||
// Show "Jump to First Unanswered" if there are unanswered questions
|
||||
// Flag current question for review
|
||||
Builder(
|
||||
builder: (context) {
|
||||
if (_attempt!.questions.isEmpty) return const SizedBox.shrink();
|
||||
final currentQuestionId = _attempt!.questions[_currentQuestionIndex].id;
|
||||
final isFlagged = _deck!.questions
|
||||
.where((q) => q.id == currentQuestionId)
|
||||
.firstOrNull
|
||||
?.isFlagged ?? false;
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_deck = DeckService.toggleQuestionFlag(
|
||||
deck: _deck!,
|
||||
questionId: currentQuestionId,
|
||||
);
|
||||
_deckStorage.saveDeckSync(_deck!);
|
||||
});
|
||||
showTopSnackBar(
|
||||
context,
|
||||
message: isFlagged ? 'Question unflagged' : 'Question flagged for review',
|
||||
backgroundColor: isFlagged ? null : Colors.orange,
|
||||
);
|
||||
},
|
||||
icon: Icon(
|
||||
isFlagged ? Icons.flag : Icons.outlined_flag,
|
||||
color: isFlagged ? Colors.red : null,
|
||||
),
|
||||
tooltip: isFlagged ? 'Unflag question' : 'Flag for review',
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_hasUnansweredQuestions)
|
||||
IconButton(
|
||||
onPressed: _jumpToFirstUnanswered,
|
||||
icon: const Icon(Icons.skip_next),
|
||||
tooltip: 'Jump to first unanswered question',
|
||||
),
|
||||
// Only show "Continue Later" button if there's progress
|
||||
if (_hasAnyProgress)
|
||||
IconButton(
|
||||
onPressed: _saveForLater,
|
||||
|
||||
@@ -143,6 +143,11 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
|
||||
appBar: AppBar(
|
||||
title: const Text('Edit Deck'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: _addQuestion,
|
||||
tooltip: 'Add Question',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.save),
|
||||
onPressed: _save,
|
||||
@@ -177,19 +182,9 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Questions Section
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Questions',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _addQuestion,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add Question'),
|
||||
),
|
||||
],
|
||||
Text(
|
||||
'Questions',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -200,6 +195,7 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
|
||||
editor: _questionEditors[index],
|
||||
questionNumber: index + 1,
|
||||
onDelete: () => _removeQuestion(index),
|
||||
onUnflag: null,
|
||||
onChanged: () => setState(() {}),
|
||||
);
|
||||
}),
|
||||
@@ -223,7 +219,7 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tap "Add Question" to get started',
|
||||
'Tap + in the app bar to add a question',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
@@ -244,14 +240,16 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
|
||||
class QuestionEditorCard extends StatefulWidget {
|
||||
final QuestionEditor editor;
|
||||
final int questionNumber;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback? onDelete;
|
||||
final VoidCallback? onUnflag;
|
||||
final VoidCallback onChanged;
|
||||
|
||||
const QuestionEditorCard({
|
||||
super.key,
|
||||
required this.editor,
|
||||
required this.questionNumber,
|
||||
required this.onDelete,
|
||||
this.onDelete,
|
||||
this.onUnflag,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@@ -276,10 +274,22 @@ class _QuestionEditorCardState extends State<QuestionEditorCard> {
|
||||
'Question ${widget.questionNumber}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
onPressed: widget.onDelete,
|
||||
tooltip: 'Delete Question',
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.onUnflag != null)
|
||||
IconButton(
|
||||
icon: Icon(Icons.flag, color: Colors.orange.shade700),
|
||||
onPressed: widget.onUnflag,
|
||||
tooltip: 'Unflag',
|
||||
),
|
||||
if (widget.onDelete != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
onPressed: widget.onDelete,
|
||||
tooltip: 'Delete Question',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -67,10 +67,11 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
|
||||
?.map((e) => e.toString())
|
||||
.toList() ??
|
||||
[],
|
||||
correctAnswerIndices: correctIndices ?? [0],
|
||||
consecutiveCorrect: questionMap['consecutiveCorrect'] as int? ?? 0,
|
||||
isKnown: questionMap['isKnown'] as bool? ?? false,
|
||||
priorityPoints: questionMap['priorityPoints'] as int? ?? 0,
|
||||
correctAnswerIndices: correctIndices ?? [0],
|
||||
consecutiveCorrect: questionMap['consecutiveCorrect'] as int? ?? 0,
|
||||
isKnown: questionMap['isKnown'] as bool? ?? false,
|
||||
isFlagged: questionMap['isFlagged'] as bool? ?? false,
|
||||
priorityPoints: questionMap['priorityPoints'] as int? ?? 0,
|
||||
lastAttemptIndex: questionMap['lastAttemptIndex'] as int? ?? -1,
|
||||
totalCorrectAttempts: questionMap['totalCorrectAttempts'] as int? ?? 0,
|
||||
totalAttempts: questionMap['totalAttempts'] as int? ?? 0,
|
||||
|
||||
@@ -627,6 +627,26 @@ class _DeckOverviewScreenState extends State<DeckOverviewScreen> {
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pushNamed(
|
||||
context,
|
||||
Routes.flaggedQuestions,
|
||||
arguments: _deck,
|
||||
).then((_) => _refreshDeck());
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.flag,
|
||||
color: _deck!.flaggedCount > 0 ? Colors.orange : null,
|
||||
),
|
||||
label: Text(
|
||||
'Flagged questions (${_deck!.flaggedCount})',
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:practice_engine/practice_engine.dart';
|
||||
import 'deck_edit_screen.dart';
|
||||
import '../services/deck_storage.dart';
|
||||
import '../utils/top_snackbar.dart';
|
||||
|
||||
/// Screen that shows flagged questions in the same editable card layout as deck edit,
|
||||
/// with Save, Unflag per question, and export/copy as JSON.
|
||||
class FlaggedQuestionsScreen extends StatefulWidget {
|
||||
const FlaggedQuestionsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<FlaggedQuestionsScreen> createState() => _FlaggedQuestionsScreenState();
|
||||
}
|
||||
|
||||
class _FlaggedQuestionsScreenState extends State<FlaggedQuestionsScreen> {
|
||||
Deck? _deck;
|
||||
final List<QuestionEditor> _editors = [];
|
||||
final DeckStorage _deckStorage = DeckStorage();
|
||||
String? _lastDeckId;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final deck = ModalRoute.of(context)?.settings.arguments as Deck?;
|
||||
if (deck == null) return;
|
||||
if (_deck?.id != deck.id || _lastDeckId != deck.id) {
|
||||
_lastDeckId = deck.id;
|
||||
_loadDeck(deck);
|
||||
}
|
||||
}
|
||||
|
||||
void _loadDeck(Deck deck) {
|
||||
final fresh = _deckStorage.getDeckSync(deck.id) ?? deck;
|
||||
setState(() {
|
||||
_deck = fresh;
|
||||
for (final e in _editors) {
|
||||
e.dispose();
|
||||
}
|
||||
_editors.clear();
|
||||
for (final q in fresh.flaggedQuestions) {
|
||||
_editors.add(QuestionEditor.fromQuestion(q));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final e in _editors) {
|
||||
e.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _unflag(int index) {
|
||||
if (_deck == null || index < 0 || index >= _editors.length) return;
|
||||
final questionId = _editors[index].originalId;
|
||||
if (questionId == null) return;
|
||||
final updatedQuestions = _deck!.questions.map((q) {
|
||||
if (q.id == questionId) return q.copyWith(isFlagged: false);
|
||||
return q;
|
||||
}).toList();
|
||||
final updatedDeck = _deck!.copyWith(questions: updatedQuestions);
|
||||
_deckStorage.saveDeckSync(updatedDeck);
|
||||
_editors[index].dispose();
|
||||
setState(() {
|
||||
_deck = updatedDeck;
|
||||
_editors.removeAt(index);
|
||||
});
|
||||
showTopSnackBar(context, message: 'Question unflagged');
|
||||
}
|
||||
|
||||
void _save() {
|
||||
if (_deck == null) return;
|
||||
for (int i = 0; i < _editors.length; i++) {
|
||||
final editor = _editors[i];
|
||||
if (!editor.isValid) {
|
||||
showTopSnackBar(
|
||||
context,
|
||||
message: 'Question ${i + 1} is invalid. Fill in all fields.',
|
||||
backgroundColor: Colors.red,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
final questionMap = Map<String, Question>.fromEntries(
|
||||
_deck!.questions.map((q) => MapEntry(q.id, q)),
|
||||
);
|
||||
for (final editor in _editors) {
|
||||
final id = editor.originalId;
|
||||
if (id == null) continue;
|
||||
final existing = questionMap[id];
|
||||
if (existing == null) continue;
|
||||
final updated = existing.copyWith(
|
||||
prompt: editor.promptController.text.trim(),
|
||||
answers: editor.answerControllers.map((c) => c.text.trim()).toList(),
|
||||
correctAnswerIndices: editor.correctAnswerIndices.toList()..sort(),
|
||||
);
|
||||
questionMap[id] = updated;
|
||||
}
|
||||
final updatedQuestions = _deck!.questions.map((q) => questionMap[q.id] ?? q).toList();
|
||||
final updatedDeck = _deck!.copyWith(questions: updatedQuestions);
|
||||
_deckStorage.saveDeckSync(updatedDeck);
|
||||
setState(() => _deck = updatedDeck);
|
||||
showTopSnackBar(
|
||||
context,
|
||||
message: 'Changes saved',
|
||||
backgroundColor: Colors.green,
|
||||
);
|
||||
}
|
||||
|
||||
void _copyJson() {
|
||||
final questions = _editors.map((e) => e.toQuestion()).toList();
|
||||
if (questions.isEmpty) {
|
||||
showTopSnackBar(context, message: 'No questions to copy');
|
||||
return;
|
||||
}
|
||||
final json = _questionsToJson(questions);
|
||||
Clipboard.setData(ClipboardData(text: json));
|
||||
showTopSnackBar(context, message: '${questions.length} question(s) copied as JSON');
|
||||
}
|
||||
|
||||
void _exportJson() {
|
||||
final questions = _editors.map((e) => e.toQuestion()).toList();
|
||||
if (questions.isEmpty) {
|
||||
showTopSnackBar(context, message: 'No questions to export');
|
||||
return;
|
||||
}
|
||||
final json = _questionsToJson(questions);
|
||||
Clipboard.setData(ClipboardData(text: json));
|
||||
showTopSnackBar(
|
||||
context,
|
||||
message: 'JSON copied to clipboard. Paste into a file to save.',
|
||||
);
|
||||
}
|
||||
|
||||
String _questionsToJson(List<Question> questions) {
|
||||
final list = questions.map((q) => {
|
||||
'id': q.id,
|
||||
'prompt': q.prompt,
|
||||
'answers': q.answers,
|
||||
'correctAnswerIndices': q.correctAnswerIndices,
|
||||
}).toList();
|
||||
return const JsonEncoder.withIndent(' ').convert(list);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_deck == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Flagged questions')),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
final hasEditors = _editors.isNotEmpty;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Flagged questions (${_editors.length})'),
|
||||
actions: [
|
||||
if (hasEditors) ...[
|
||||
IconButton(
|
||||
onPressed: _save,
|
||||
icon: const Icon(Icons.save),
|
||||
tooltip: 'Save',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _copyJson,
|
||||
icon: const Icon(Icons.copy),
|
||||
tooltip: 'Copy as JSON',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _exportJson,
|
||||
icon: const Icon(Icons.file_download),
|
||||
tooltip: 'Export as JSON',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
body: !hasEditors
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.flag_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No flagged questions',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Flag questions during an attempt to see them here.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
...List.generate(_editors.length, (index) {
|
||||
return QuestionEditorCard(
|
||||
key: ValueKey('flagged_${_editors[index].originalId}_$index'),
|
||||
editor: _editors[index],
|
||||
questionNumber: index + 1,
|
||||
onDelete: null,
|
||||
onUnflag: () => _unflag(index),
|
||||
onChanged: () => setState(() {}),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user