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.
decky/lib/screens/deck_edit_screen.dart

512 lines
16 KiB

import 'package:flutter/material.dart';
import '../utils/top_snackbar.dart';
import 'package:practice_engine/practice_engine.dart';
import '../services/deck_storage.dart';
class DeckEditScreen extends StatefulWidget {
const DeckEditScreen({super.key});
@override
State<DeckEditScreen> createState() => _DeckEditScreenState();
}
class _DeckEditScreenState extends State<DeckEditScreen> {
Deck? _deck;
late TextEditingController _titleController;
late TextEditingController _descriptionController;
final List<QuestionEditor> _questionEditors = [];
final DeckStorage _deckStorage = DeckStorage();
final ScrollController _scrollController = ScrollController();
int? _focusNewQuestionIndex;
@override
void initState() {
super.initState();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_deck == null) {
final args = ModalRoute.of(context)?.settings.arguments;
if (args is Deck) {
_initializeFromDeck(args);
}
}
}
void _initializeFromDeck(Deck deck) {
setState(() {
_deck = deck;
_titleController = TextEditingController(text: deck.title);
_descriptionController = TextEditingController(text: deck.description);
_questionEditors.clear();
for (final question in deck.questions) {
_questionEditors.add(QuestionEditor.fromQuestion(question));
}
});
}
@override
void dispose() {
_scrollController.dispose();
_titleController.dispose();
_descriptionController.dispose();
for (final editor in _questionEditors) {
editor.dispose();
}
super.dispose();
}
void _addQuestion() {
setState(() {
_questionEditors.add(QuestionEditor.empty());
_focusNewQuestionIndex = _questionEditors.length - 1;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final pos = _scrollController.position;
if (pos.maxScrollExtent > pos.pixels) {
_scrollController.animateTo(
pos.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
Future.microtask(() {
if (mounted) setState(() => _focusNewQuestionIndex = null);
});
});
}
void _removeQuestion(int index) {
setState(() {
_questionEditors.removeAt(index);
});
}
void _save() {
if (_deck == null) return;
final title = _titleController.text.trim();
if (title.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Deck title cannot be empty'),
backgroundColor: Colors.red,
),
);
return;
}
// Validate all questions
final questions = <Question>[];
for (int i = 0; i < _questionEditors.length; i++) {
final editor = _questionEditors[i];
if (!editor.isValid) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Question ${i + 1} is invalid. Please fill in all fields.'),
backgroundColor: Colors.red,
),
);
return;
}
questions.add(editor.toQuestion());
}
if (questions.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Deck must have at least one question'),
backgroundColor: Colors.red,
),
);
return;
}
// Preserve attempt history and other deck data when editing
final updatedDeck = _deck!.copyWith(
title: title,
description: _descriptionController.text.trim(),
questions: questions,
// Preserve attempt history, config, and current attempt index
attemptHistory: _deck!.attemptHistory,
config: _deck!.config,
currentAttemptIndex: _deck!.currentAttemptIndex,
);
_deckStorage.saveDeckSync(updatedDeck);
showTopSnackBar(
context,
message: 'Deck saved successfully',
backgroundColor: Colors.green,
duration: const Duration(seconds: 1),
);
Navigator.pop(context, updatedDeck);
}
@override
Widget build(BuildContext context) {
if (_deck == null) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
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,
tooltip: 'Save Deck',
),
],
),
body: SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Deck Title
TextField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Deck Title',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
// Deck Description
TextField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Description (optional)',
border: OutlineInputBorder(),
),
maxLines: 3,
),
const SizedBox(height: 24),
// Questions Section
Text(
'Questions',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
// Questions List
...List.generate(_questionEditors.length, (index) {
return QuestionEditorCard(
key: ValueKey('question_$index'),
editor: _questionEditors[index],
questionNumber: index + 1,
onDelete: () => _removeQuestion(index),
onUnflag: null,
onChanged: () => setState(() {}),
requestFocusOnPrompt: _focusNewQuestionIndex == index,
);
}),
if (_questionEditors.isEmpty)
Card(
child: Padding(
padding: const EdgeInsets.all(32),
child: Center(
child: Column(
children: [
Icon(
Icons.quiz_outlined,
size: 48,
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
),
const SizedBox(height: 16),
Text(
'No questions yet',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 8),
Text(
'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),
),
),
],
),
),
),
),
],
),
),
);
}
}
class QuestionEditorCard extends StatefulWidget {
final QuestionEditor editor;
final int questionNumber;
final VoidCallback? onDelete;
final VoidCallback? onUnflag;
final VoidCallback onChanged;
final bool requestFocusOnPrompt;
const QuestionEditorCard({
super.key,
required this.editor,
required this.questionNumber,
this.onDelete,
this.onUnflag,
required this.onChanged,
this.requestFocusOnPrompt = false,
});
@override
State<QuestionEditorCard> createState() => _QuestionEditorCardState();
}
class _QuestionEditorCardState extends State<QuestionEditorCard> {
final FocusNode _promptFocusNode = FocusNode();
@override
void dispose() {
_promptFocusNode.dispose();
super.dispose();
}
@override
void didUpdateWidget(QuestionEditorCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.requestFocusOnPrompt && !oldWidget.requestFocusOnPrompt) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _promptFocusNode.requestFocus();
});
}
}
@override
Widget build(BuildContext context) {
if (widget.requestFocusOnPrompt) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _promptFocusNode.requestFocus();
});
}
return Card(
margin: const EdgeInsets.only(bottom: 16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'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,
tooltip: 'Delete Question',
),
],
),
],
),
const SizedBox(height: 12),
// Question Prompt
TextField(
controller: widget.editor.promptController,
focusNode: _promptFocusNode,
decoration: const InputDecoration(
labelText: 'Question',
border: OutlineInputBorder(),
),
maxLines: 2,
),
const SizedBox(height: 16),
// Answers
Text(
'Answers',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
...List.generate(widget.editor.answerControllers.length, (index) {
final isCorrect = widget.editor.correctAnswerIndices.contains(index);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Checkbox(
value: isCorrect,
onChanged: (value) {
setState(() {
if (value == true) {
widget.editor.correctAnswerIndices.add(index);
} else {
widget.editor.correctAnswerIndices.remove(index);
// Ensure at least one answer is marked as correct
if (widget.editor.correctAnswerIndices.isEmpty &&
widget.editor.answerControllers.length > 0) {
widget.editor.correctAnswerIndices.add(0);
}
}
});
widget.onChanged();
},
),
Expanded(
child: TextField(
controller: widget.editor.answerControllers[index],
decoration: InputDecoration(
labelText: 'Answer ${index + 1}',
border: const OutlineInputBorder(),
suffixIcon: isCorrect
? const Icon(Icons.check_circle, color: Colors.green)
: null,
),
),
),
],
),
);
}),
// Add/Remove Answer buttons
Row(
children: [
TextButton.icon(
onPressed: () {
setState(() {
widget.editor.answerControllers.add(TextEditingController());
});
widget.onChanged();
},
icon: const Icon(Icons.add),
label: const Text('Add Answer'),
),
if (widget.editor.answerControllers.length > 2)
TextButton.icon(
onPressed: () {
setState(() {
final lastIndex = widget.editor.answerControllers.length - 1;
// Remove the index from correct answers if it was marked
widget.editor.correctAnswerIndices.remove(lastIndex);
// Adjust indices if needed
widget.editor.correctAnswerIndices = widget.editor.correctAnswerIndices
.where((idx) => idx < lastIndex)
.toSet();
// Ensure at least one answer is marked as correct
if (widget.editor.correctAnswerIndices.isEmpty &&
widget.editor.answerControllers.length > 1) {
widget.editor.correctAnswerIndices.add(0);
}
widget.editor.answerControllers.removeLast().dispose();
});
widget.onChanged();
},
icon: const Icon(Icons.remove),
label: const Text('Remove Answer'),
),
],
),
],
),
),
);
}
}
class QuestionEditor {
final TextEditingController promptController;
final List<TextEditingController> answerControllers;
Set<int> correctAnswerIndices;
final String? originalId;
QuestionEditor({
required this.promptController,
required this.answerControllers,
Set<int>? correctAnswerIndices,
this.originalId,
}) : correctAnswerIndices = correctAnswerIndices ?? {0};
factory QuestionEditor.fromQuestion(Question question) {
return QuestionEditor(
promptController: TextEditingController(text: question.prompt),
answerControllers: question.answers
.map((answer) => TextEditingController(text: answer))
.toList(),
correctAnswerIndices: question.correctIndices.toSet(),
originalId: question.id,
);
}
factory QuestionEditor.empty() {
return QuestionEditor(
promptController: TextEditingController(),
answerControllers: [
TextEditingController(),
TextEditingController(),
],
correctAnswerIndices: {0},
);
}
bool get isValid {
if (promptController.text.trim().isEmpty) return false;
if (answerControllers.length < 2) return false;
for (final controller in answerControllers) {
if (controller.text.trim().isEmpty) return false;
}
if (correctAnswerIndices.isEmpty) return false;
if (correctAnswerIndices.any((idx) => idx < 0 || idx >= answerControllers.length)) {
return false;
}
return true;
}
Question toQuestion() {
return Question(
id: originalId ?? DateTime.now().millisecondsSinceEpoch.toString(),
prompt: promptController.text.trim(),
answers: answerControllers.map((c) => c.text.trim()).toList(),
correctAnswerIndices: correctAnswerIndices.toList()..sort(),
);
}
void dispose() {
promptController.dispose();
for (final controller in answerControllers) {
controller.dispose();
}
}
}

Powered by TurnKey Linux.