diff --git a/lib/routes.dart b/lib/routes.dart index cf5c9d9..2b03529 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -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 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(), }; } } diff --git a/lib/screens/attempt_screen.dart b/lib/screens/attempt_screen.dart index 884901b..546c82c 100644 --- a/lib/screens/attempt_screen.dart +++ b/lib/screens/attempt_screen.dart @@ -572,14 +572,44 @@ class _AttemptScreenState extends State { 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, diff --git a/lib/screens/deck_edit_screen.dart b/lib/screens/deck_edit_screen.dart index d8b20f9..5943db9 100644 --- a/lib/screens/deck_edit_screen.dart +++ b/lib/screens/deck_edit_screen.dart @@ -143,6 +143,11 @@ class _DeckEditScreenState extends State { 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 { 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 { editor: _questionEditors[index], questionNumber: index + 1, onDelete: () => _removeQuestion(index), + onUnflag: null, onChanged: () => setState(() {}), ); }), @@ -223,7 +219,7 @@ class _DeckEditScreenState extends State { ), 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 { 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 { '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', + ), + ], ), ], ), diff --git a/lib/screens/deck_import_screen.dart b/lib/screens/deck_import_screen.dart index 3492213..6b3f0ed 100644 --- a/lib/screens/deck_import_screen.dart +++ b/lib/screens/deck_import_screen.dart @@ -67,10 +67,11 @@ class _DeckImportScreenState extends State { ?.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, diff --git a/lib/screens/deck_overview_screen.dart b/lib/screens/deck_overview_screen.dart index beb4b8f..9e8c953 100644 --- a/lib/screens/deck_overview_screen.dart +++ b/lib/screens/deck_overview_screen.dart @@ -627,6 +627,26 @@ class _DeckOverviewScreenState extends State { 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), + ), + ), ], ), ), diff --git a/lib/screens/flagged_questions_screen.dart b/lib/screens/flagged_questions_screen.dart new file mode 100644 index 0000000..4dec977 --- /dev/null +++ b/lib/screens/flagged_questions_screen.dart @@ -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 createState() => _FlaggedQuestionsScreenState(); +} + +class _FlaggedQuestionsScreenState extends State { + Deck? _deck; + final List _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.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 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(() {}), + ); + }), + ], + ), + ), + ); + } +} diff --git a/lib/services/deck_storage.dart b/lib/services/deck_storage.dart index e393b1f..925e7c1 100644 --- a/lib/services/deck_storage.dart +++ b/lib/services/deck_storage.dart @@ -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, diff --git a/packages/practice_engine/lib/logic/deck_service.dart b/packages/practice_engine/lib/logic/deck_service.dart index 513c0d8..ede9ccd 100644 --- a/packages/practice_engine/lib/logic/deck_service.dart +++ b/packages/practice_engine/lib/logic/deck_service.dart @@ -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. diff --git a/packages/practice_engine/lib/models/deck.dart b/packages/practice_engine/lib/models/deck.dart index 1b8ddff..ea11085 100644 --- a/packages/practice_engine/lib/models/deck.dart +++ b/packages/practice_engine/lib/models/deck.dart @@ -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 get flaggedQuestions => questions.where((q) => q.isFlagged).toList(); + /// Practice percentage: (known / total) * 100 double get practicePercentage { if (questions.isEmpty) return 0.0; diff --git a/packages/practice_engine/lib/models/question.dart b/packages/practice_engine/lib/models/question.dart index 4bf8eb2..1834f4b 100644 --- a/packages/practice_engine/lib/models/question.dart +++ b/packages/practice_engine/lib/models/question.dart @@ -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 ^