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

456 lines
14 KiB

import 'package:flutter/material.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();
@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() {
_titleController.dispose();
_descriptionController.dispose();
for (final editor in _questionEditors) {
editor.dispose();
}
super.dispose();
}
void _addQuestion() {
setState(() {
_questionEditors.add(QuestionEditor.empty());
});
}
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.saveDeck(updatedDeck);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Deck saved successfully'),
backgroundColor: Colors.green,
duration: 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.save),
onPressed: _save,
tooltip: 'Save Deck',
),
],
),
body: SingleChildScrollView(
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
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
...List.generate(_questionEditors.length, (index) {
return QuestionEditorCard(
key: ValueKey('question_$index'),
editor: _questionEditors[index],
questionNumber: index + 1,
onDelete: () => _removeQuestion(index),
onChanged: () => setState(() {}),
);
}),
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 "Add Question" to get started',
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 onChanged;
const QuestionEditorCard({
super.key,
required this.editor,
required this.questionNumber,
required this.onDelete,
required this.onChanged,
});
@override
State<QuestionEditorCard> createState() => _QuestionEditorCardState();
}
class _QuestionEditorCardState extends State<QuestionEditorCard> {
@override
Widget build(BuildContext context) {
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,
),
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,
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.