You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
300 lines
9.7 KiB
300 lines
9.7 KiB
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 _removeQuestion(int index) {
|
|
if (_deck == null || index < 0 || index >= _editors.length) return;
|
|
final questionId = _editors[index].originalId;
|
|
if (questionId == null) return;
|
|
final updatedQuestions =
|
|
_deck!.questions.where((q) => q.id != questionId).toList();
|
|
final updatedDeck = _deck!.copyWith(questions: updatedQuestions);
|
|
_deckStorage.saveDeckSync(updatedDeck);
|
|
_editors[index].dispose();
|
|
setState(() {
|
|
_deck = updatedDeck;
|
|
_editors.removeAt(index);
|
|
});
|
|
showTopSnackBar(context, message: 'Question removed from deck');
|
|
}
|
|
|
|
Future<void> _removeAllQuestions() async {
|
|
if (_deck == null || _editors.isEmpty) return;
|
|
final count = _editors.length;
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Remove all flagged questions?'),
|
|
content: Text(
|
|
'Remove all $count flagged question${count == 1 ? '' : 's'} from the deck? This cannot be undone.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: Theme.of(context).colorScheme.error,
|
|
),
|
|
child: const Text('Remove all'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed != true || !mounted) return;
|
|
final idsToRemove =
|
|
_editors.map((e) => e.originalId).whereType<String>().toSet();
|
|
final updatedQuestions =
|
|
_deck!.questions.where((q) => !idsToRemove.contains(q.id)).toList();
|
|
final updatedDeck = _deck!.copyWith(questions: updatedQuestions);
|
|
_deckStorage.saveDeckSync(updatedDeck);
|
|
for (final e in _editors) {
|
|
e.dispose();
|
|
}
|
|
setState(() {
|
|
_deck = updatedDeck;
|
|
_editors.clear();
|
|
});
|
|
showTopSnackBar(
|
|
context,
|
|
message: '$count question${count == 1 ? '' : 's'} removed from deck',
|
|
backgroundColor: Colors.green,
|
|
);
|
|
}
|
|
|
|
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 explanationText = editor.explanationController.text.trim();
|
|
final updated = existing.copyWith(
|
|
prompt: editor.promptController.text.trim(),
|
|
explanation: explanationText.isEmpty ? null : explanationText,
|
|
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,
|
|
if (q.explanation != null && q.explanation!.isNotEmpty) 'explanation': q.explanation,
|
|
'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: _removeAllQuestions,
|
|
icon: const Icon(Icons.delete_forever),
|
|
tooltip: 'Remove all from deck',
|
|
),
|
|
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: () => _removeQuestion(index),
|
|
onUnflag: () => _unflag(index),
|
|
onChanged: () => setState(() {}),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|