master
gitea 1 month ago
parent 6b8cf10e3c
commit 10a4837ee0

@ -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,20 +182,10 @@ 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'),
),
],
),
const SizedBox(height: 16),
// Questions List
@ -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,6 +274,16 @@ class _QuestionEditorCardState extends State<QuestionEditorCard> {
'Question ${widget.questionNumber}',
style: Theme.of(context).textTheme.titleMedium,
),
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,
@ -283,6 +291,8 @@ class _QuestionEditorCardState extends State<QuestionEditorCard> {
),
],
),
],
),
const SizedBox(height: 12),
// Question Prompt
TextField(

@ -70,6 +70,7 @@ class _DeckImportScreenState extends State<DeckImportScreen> {
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,

@ -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 ^

Loading…
Cancel
Save

Powered by TurnKey Linux.