flagging
This commit is contained in:
@@ -7,6 +7,7 @@ import 'screens/deck_edit_screen.dart';
|
||||
import 'screens/deck_create_screen.dart';
|
||||
import 'screens/attempt_screen.dart';
|
||||
import 'screens/attempt_result_screen.dart';
|
||||
import 'screens/flagged_questions_screen.dart';
|
||||
|
||||
class Routes {
|
||||
static const String deckList = '/';
|
||||
@@ -17,6 +18,7 @@ class Routes {
|
||||
static const String deckCreate = '/deck-create';
|
||||
static const String attempt = '/attempt';
|
||||
static const String attemptResult = '/attempt-result';
|
||||
static const String flaggedQuestions = '/flagged-questions';
|
||||
|
||||
static Map<String, WidgetBuilder> get routes {
|
||||
return {
|
||||
@@ -28,6 +30,7 @@ class Routes {
|
||||
deckCreate: (context) => const DeckCreateScreen(),
|
||||
attempt: (context) => const AttemptScreen(),
|
||||
attemptResult: (context) => const AttemptResultScreen(),
|
||||
flaggedQuestions: (context) => const FlaggedQuestionsScreen(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(() {}),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ class DeckStorage {
|
||||
'correctAnswerIndices': q.correctAnswerIndices,
|
||||
'consecutiveCorrect': q.consecutiveCorrect,
|
||||
'isKnown': q.isKnown,
|
||||
'isFlagged': q.isFlagged,
|
||||
'priorityPoints': q.priorityPoints,
|
||||
'lastAttemptIndex': q.lastAttemptIndex,
|
||||
'totalCorrectAttempts': q.totalCorrectAttempts,
|
||||
@@ -137,6 +138,7 @@ class DeckStorage {
|
||||
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,
|
||||
|
||||
@@ -25,6 +25,20 @@ class DeckService {
|
||||
return deck.copyWith(questions: updatedQuestions);
|
||||
}
|
||||
|
||||
/// Toggles the flagged state of a question.
|
||||
static Deck toggleQuestionFlag({
|
||||
required Deck deck,
|
||||
required String questionId,
|
||||
}) {
|
||||
final updatedQuestions = deck.questions.map((q) {
|
||||
if (q.id == questionId) {
|
||||
return q.copyWith(isFlagged: !q.isFlagged);
|
||||
}
|
||||
return q;
|
||||
}).toList();
|
||||
return deck.copyWith(questions: updatedQuestions);
|
||||
}
|
||||
|
||||
/// Marks a question as needs practice (manual override).
|
||||
///
|
||||
/// Sets isKnown to false and streak to 0, but keeps priority unchanged.
|
||||
|
||||
@@ -70,6 +70,12 @@ class Deck {
|
||||
/// Number of questions marked as known.
|
||||
int get knownCount => questions.where((q) => q.isKnown).length;
|
||||
|
||||
/// Number of questions the user has flagged.
|
||||
int get flaggedCount => questions.where((q) => q.isFlagged).length;
|
||||
|
||||
/// Questions that the user has flagged.
|
||||
List<Question> get flaggedQuestions => questions.where((q) => q.isFlagged).toList();
|
||||
|
||||
/// Practice percentage: (known / total) * 100
|
||||
double get practicePercentage {
|
||||
if (questions.isEmpty) return 0.0;
|
||||
|
||||
@@ -24,6 +24,9 @@ class Question {
|
||||
/// Whether this question is considered "known".
|
||||
final bool isKnown;
|
||||
|
||||
/// Whether the user has flagged this question for review.
|
||||
final bool isFlagged;
|
||||
|
||||
/// Priority points (higher = more likely to be selected).
|
||||
final int priorityPoints;
|
||||
|
||||
@@ -44,6 +47,7 @@ class Question {
|
||||
@Deprecated('Use correctAnswerIndices instead') int? correctAnswerIndex,
|
||||
this.consecutiveCorrect = 0,
|
||||
this.isKnown = false,
|
||||
this.isFlagged = false,
|
||||
this.priorityPoints = 0,
|
||||
this.lastAttemptIndex = -1,
|
||||
this.totalCorrectAttempts = 0,
|
||||
@@ -61,6 +65,7 @@ class Question {
|
||||
@Deprecated('Use correctAnswerIndices instead') int? correctAnswerIndex,
|
||||
int? consecutiveCorrect,
|
||||
bool? isKnown,
|
||||
bool? isFlagged,
|
||||
int? priorityPoints,
|
||||
int? lastAttemptIndex,
|
||||
int? totalCorrectAttempts,
|
||||
@@ -74,6 +79,7 @@ class Question {
|
||||
correctAnswerIndex: correctAnswerIndex ?? this.correctAnswerIndex,
|
||||
consecutiveCorrect: consecutiveCorrect ?? this.consecutiveCorrect,
|
||||
isKnown: isKnown ?? this.isKnown,
|
||||
isFlagged: isFlagged ?? this.isFlagged,
|
||||
priorityPoints: priorityPoints ?? this.priorityPoints,
|
||||
lastAttemptIndex: lastAttemptIndex ?? this.lastAttemptIndex,
|
||||
totalCorrectAttempts: totalCorrectAttempts ?? this.totalCorrectAttempts,
|
||||
@@ -117,6 +123,7 @@ class Question {
|
||||
correctAnswerIndices.toString() == other.correctAnswerIndices.toString() &&
|
||||
consecutiveCorrect == other.consecutiveCorrect &&
|
||||
isKnown == other.isKnown &&
|
||||
isFlagged == other.isFlagged &&
|
||||
priorityPoints == other.priorityPoints &&
|
||||
lastAttemptIndex == other.lastAttemptIndex &&
|
||||
totalCorrectAttempts == other.totalCorrectAttempts &&
|
||||
@@ -130,6 +137,7 @@ class Question {
|
||||
correctAnswerIndices.hashCode ^
|
||||
consecutiveCorrect.hashCode ^
|
||||
isKnown.hashCode ^
|
||||
isFlagged.hashCode ^
|
||||
priorityPoints.hashCode ^
|
||||
lastAttemptIndex.hashCode ^
|
||||
totalCorrectAttempts.hashCode ^
|
||||
|
||||
Reference in New Issue
Block a user