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_config_screen.dart

685 lines
25 KiB

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/top_snackbar.dart';
import 'package:practice_engine/practice_engine.dart';
import '../services/deck_storage.dart';
import '../routes.dart';
class DeckConfigScreen extends StatefulWidget {
const DeckConfigScreen({super.key});
@override
State<DeckConfigScreen> createState() => _DeckConfigScreenState();
}
class _DeckConfigScreenState extends State<DeckConfigScreen> {
Deck? _deck;
DeckConfig? _config;
late TextEditingController _consecutiveController;
late TextEditingController _attemptSizeController;
late TextEditingController _priorityIncreaseController;
late TextEditingController _priorityDecreaseController;
late TextEditingController _timeLimitHoursController;
late TextEditingController _timeLimitMinutesController;
late TextEditingController _timeLimitSecondsController;
late bool _immediateFeedback;
late bool _includeKnownInAttempts;
late bool _shuffleAnswerOrder;
late bool _excludeFlaggedQuestions;
bool _timeLimitEnabled = false;
String? _lastDeckId;
int _configHashCode = 0;
@override
void initState() {
super.initState();
_deck = null;
_lastDeckId = null;
_configHashCode = 0;
// Initialize controllers with default values
_timeLimitHoursController = TextEditingController();
_timeLimitMinutesController = TextEditingController();
_timeLimitSecondsController = TextEditingController();
}
void _initializeFromDeck(Deck deck) {
// Dispose old controllers if they exist
if (_deck != null) {
_consecutiveController.dispose();
_attemptSizeController.dispose();
_priorityIncreaseController.dispose();
_priorityDecreaseController.dispose();
_timeLimitHoursController.dispose();
_timeLimitMinutesController.dispose();
_timeLimitSecondsController.dispose();
}
setState(() {
_deck = deck;
_config = deck.config;
_lastDeckId = deck.id;
_configHashCode = deck.config.hashCode;
// Create new controllers with current config values
_consecutiveController = TextEditingController(
text: _config!.requiredConsecutiveCorrect.toString(),
);
_attemptSizeController = TextEditingController(
text: _config!.defaultAttemptSize.toString(),
);
_priorityIncreaseController = TextEditingController(
text: _config!.priorityIncreaseOnIncorrect.toString(),
);
_priorityDecreaseController = TextEditingController(
text: _config!.priorityDecreaseOnCorrect.toString(),
);
_immediateFeedback = _config!.immediateFeedbackEnabled;
_includeKnownInAttempts = _config!.includeKnownInAttempts;
_shuffleAnswerOrder = _config!.shuffleAnswerOrder;
_excludeFlaggedQuestions = _config!.excludeFlaggedQuestions;
// Initialize time limit controllers
_timeLimitEnabled = _config!.timeLimitSeconds != null;
final totalSeconds = _config!.timeLimitSeconds ?? 0;
final hours = totalSeconds ~/ 3600;
final minutes = (totalSeconds % 3600) ~/ 60;
final seconds = totalSeconds % 60;
_timeLimitHoursController = TextEditingController(
text: hours > 0 ? hours.toString() : '',
);
_timeLimitMinutesController = TextEditingController(
text: minutes > 0 ? minutes.toString() : '',
);
_timeLimitSecondsController = TextEditingController(
text: seconds > 0 ? seconds.toString() : '',
);
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Get deck from route arguments
final args = ModalRoute.of(context)?.settings.arguments;
final newDeck = args is Deck ? args : _createSampleDeck();
// Check if we need to update: first load, different deck, or config changed
final needsUpdate = _deck == null ||
_lastDeckId != newDeck.id ||
_configHashCode != newDeck.config.hashCode;
if (needsUpdate) {
_initializeFromDeck(newDeck);
}
}
Deck _createSampleDeck() {
return Deck(
id: 'sample',
title: 'Sample',
description: 'Sample',
questions: [],
config: const DeckConfig(),
);
}
@override
void dispose() {
_consecutiveController.dispose();
_attemptSizeController.dispose();
_priorityIncreaseController.dispose();
_priorityDecreaseController.dispose();
_timeLimitHoursController.dispose();
_timeLimitMinutesController.dispose();
_timeLimitSecondsController.dispose();
super.dispose();
}
/// Builds config from current form state, validates, persists if valid.
/// Returns true if saved, false if validation failed.
bool _applyAndPersist() {
if (_deck == null || _config == null) return false;
final consecutive = int.tryParse(_consecutiveController.text);
final attemptSize = int.tryParse(_attemptSizeController.text);
final priorityIncrease = int.tryParse(_priorityIncreaseController.text);
final priorityDecrease = int.tryParse(_priorityDecreaseController.text);
if (consecutive == null ||
attemptSize == null ||
priorityIncrease == null ||
priorityDecrease == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please enter valid numbers for all fields'),
backgroundColor: Colors.red,
),
);
return false;
}
if (consecutive < 1 ||
attemptSize < 1 ||
priorityIncrease < 0 ||
priorityDecrease < 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Values must be positive (priority changes >= 0)'),
backgroundColor: Colors.red,
),
);
return false;
}
// Calculate time limit in seconds
int? timeLimitSeconds;
if (_timeLimitEnabled) {
final hours = int.tryParse(_timeLimitHoursController.text) ?? 0;
final minutes = int.tryParse(_timeLimitMinutesController.text) ?? 0;
final seconds = int.tryParse(_timeLimitSecondsController.text) ?? 0;
final totalSeconds = hours * 3600 + minutes * 60 + seconds;
if (totalSeconds > 0) {
timeLimitSeconds = totalSeconds;
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Time limit must be greater than 0'),
backgroundColor: Colors.red,
),
);
return false;
}
}
final updatedConfig = DeckConfig(
requiredConsecutiveCorrect: consecutive,
defaultAttemptSize: attemptSize,
priorityIncreaseOnIncorrect: priorityIncrease,
priorityDecreaseOnCorrect: priorityDecrease,
immediateFeedbackEnabled: _immediateFeedback,
includeKnownInAttempts: _includeKnownInAttempts,
shuffleAnswerOrder: _shuffleAnswerOrder,
excludeFlaggedQuestions: _excludeFlaggedQuestions,
timeLimitSeconds: timeLimitSeconds,
);
final updatedDeck = _deck!.copyWith(config: updatedConfig);
final deckStorage = DeckStorage();
deckStorage.saveDeckSync(updatedDeck);
setState(() {
_deck = updatedDeck;
_config = updatedConfig;
_configHashCode = updatedConfig.hashCode;
});
return true;
}
Map<String, dynamic> _configToJsonMap() {
int? timeLimitSeconds;
if (_timeLimitEnabled) {
final hours = int.tryParse(_timeLimitHoursController.text) ?? 0;
final minutes = int.tryParse(_timeLimitMinutesController.text) ?? 0;
final seconds = int.tryParse(_timeLimitSecondsController.text) ?? 0;
timeLimitSeconds = hours * 3600 + minutes * 60 + seconds;
if (timeLimitSeconds == 0) timeLimitSeconds = null;
}
return {
'requiredConsecutiveCorrect': int.tryParse(_consecutiveController.text) ?? _config!.requiredConsecutiveCorrect,
'defaultAttemptSize': int.tryParse(_attemptSizeController.text) ?? _config!.defaultAttemptSize,
'priorityIncreaseOnIncorrect': int.tryParse(_priorityIncreaseController.text) ?? _config!.priorityIncreaseOnIncorrect,
'priorityDecreaseOnCorrect': int.tryParse(_priorityDecreaseController.text) ?? _config!.priorityDecreaseOnCorrect,
'immediateFeedbackEnabled': _immediateFeedback,
'includeKnownInAttempts': _includeKnownInAttempts,
'shuffleAnswerOrder': _shuffleAnswerOrder,
'excludeFlaggedQuestions': _excludeFlaggedQuestions,
if (timeLimitSeconds != null) 'timeLimitSeconds': timeLimitSeconds,
};
}
void _showAsJson() {
if (_deck == null || _config == null) return;
final map = _configToJsonMap();
final jsonString = const JsonEncoder.withIndent(' ').convert(map);
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Attempt Settings as JSON'),
content: SingleChildScrollView(
child: SelectableText(
jsonString,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
FilledButton.icon(
onPressed: () {
Clipboard.setData(ClipboardData(text: jsonString));
Navigator.pop(context);
showTopSnackBar(
context,
message: 'JSON copied to clipboard',
backgroundColor: Colors.green,
duration: const Duration(seconds: 1),
);
},
icon: const Icon(Icons.copy, size: 20),
label: const Text('Copy'),
),
],
),
);
}
void _editDeck() {
if (_deck == null) return;
Navigator.pushNamed(
context,
Routes.deckEdit,
arguments: _deck,
).then((updatedDeck) {
if (updatedDeck != null && updatedDeck is Deck && mounted) {
// Reload the deck from storage to get the latest state
final deckStorage = DeckStorage();
final refreshedDeck = deckStorage.getDeckSync(updatedDeck.id);
if (refreshedDeck != null) {
setState(() {
_deck = refreshedDeck;
_initializeFromDeck(refreshedDeck);
});
}
}
});
}
Future<void> _resetDeck() async {
if (_deck == null) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Reset Deck'),
content: const Text(
'Are you sure you want to reset this deck? This will erase all attempt data, progress, and statistics. This action cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
style: FilledButton.styleFrom(
backgroundColor: Colors.red,
),
child: const Text('Reset'),
),
],
),
);
if (confirmed == true && _deck != null) {
// Reset the deck: clear all progress, attempt history, and reset attempt counts
final resetDeck = DeckService.resetDeck(
deck: _deck!,
resetAttemptCounts: true,
clearAttemptHistory: true,
);
// Save the reset deck to storage
final deckStorage = DeckStorage();
deckStorage.saveDeckSync(resetDeck);
// Show success message
if (mounted) {
showTopSnackBar(
context,
message: 'Deck has been reset successfully',
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
);
}
// Return the reset deck to update the parent screen
if (mounted) {
Navigator.pop(context, resetDeck);
}
}
}
@override
Widget build(BuildContext context) {
if (_deck == null || _config == null) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
_applyAndPersist();
if (mounted) Navigator.pop(context, _deck);
},
child: Scaffold(
appBar: AppBar(
title: const Text('Attempt Settings'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
_applyAndPersist();
if (mounted) Navigator.pop(context, _deck);
},
),
actions: [
IconButton(
icon: const Icon(Icons.code),
onPressed: _showAsJson,
tooltip: 'Show as JSON',
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Practice Settings',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 24),
// Required Consecutive Correct
TextField(
controller: _consecutiveController,
decoration: const InputDecoration(
labelText: 'Required Consecutive Correct',
helperText: 'Number of correct answers in a row to mark as known',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onEditingComplete: _applyAndPersist,
),
const SizedBox(height: 16),
// Default Attempt Size
TextField(
controller: _attemptSizeController,
decoration: const InputDecoration(
labelText: 'Default Questions Per Attempt',
helperText: 'Number of questions to include in each attempt',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onEditingComplete: _applyAndPersist,
),
const SizedBox(height: 16),
// Priority Increase
TextField(
controller: _priorityIncreaseController,
decoration: const InputDecoration(
labelText: 'Priority Increase on Incorrect',
helperText: 'Priority points added when answered incorrectly',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onEditingComplete: _applyAndPersist,
),
const SizedBox(height: 16),
// Priority Decrease
TextField(
controller: _priorityDecreaseController,
decoration: const InputDecoration(
labelText: 'Priority Decrease on Correct',
helperText: 'Priority points removed when answered correctly',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onEditingComplete: _applyAndPersist,
),
const SizedBox(height: 16),
// Immediate Feedback Toggle
SwitchListTile(
title: const Text('Immediate Feedback'),
subtitle: const Text(
'Show correct/incorrect immediately after answering',
),
value: _immediateFeedback,
onChanged: (value) {
setState(() {
_immediateFeedback = value;
});
_applyAndPersist();
},
),
const SizedBox(height: 16),
// Include Known in Attempts Toggle
SwitchListTile(
title: const Text('Include Known in Attempts'),
subtitle: const Text(
'Include questions marked as known in practice attempts',
),
value: _includeKnownInAttempts,
onChanged: (value) {
setState(() {
_includeKnownInAttempts = value;
});
_applyAndPersist();
},
),
const SizedBox(height: 16),
// Shuffle Answer Order Toggle
SwitchListTile(
title: const Text('Shuffle Answer Order'),
subtitle: const Text(
'Show answer options in random order each attempt',
),
value: _shuffleAnswerOrder,
onChanged: (value) {
setState(() {
_shuffleAnswerOrder = value;
});
_applyAndPersist();
},
),
const SizedBox(height: 16),
// Exclude Flagged Questions Toggle
SwitchListTile(
title: const Text('Exclude flagged questions'),
subtitle: const Text(
'Flagged questions are not used in attempts and do not count in progress',
),
value: _excludeFlaggedQuestions,
onChanged: (value) {
setState(() {
_excludeFlaggedQuestions = value;
});
_applyAndPersist();
},
),
const SizedBox(height: 16),
// Time Limit Section
SwitchListTile(
title: const Text('Enable Time Limit'),
subtitle: const Text(
'Set a time limit for attempts',
),
value: _timeLimitEnabled,
onChanged: (value) {
setState(() {
_timeLimitEnabled = value;
if (!value) {
_timeLimitHoursController.clear();
_timeLimitMinutesController.clear();
_timeLimitSecondsController.clear();
}
});
_applyAndPersist();
},
),
if (_timeLimitEnabled) ...[
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextField(
controller: _timeLimitHoursController,
decoration: const InputDecoration(
labelText: 'Hours',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onEditingComplete: _applyAndPersist,
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _timeLimitMinutesController,
decoration: const InputDecoration(
labelText: 'Minutes',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onEditingComplete: _applyAndPersist,
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _timeLimitSecondsController,
decoration: const InputDecoration(
labelText: 'Seconds',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
onEditingComplete: _applyAndPersist,
),
),
],
),
],
],
),
),
),
const SizedBox(height: 24),
// Edit Deck Section
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Deck Management',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
Text(
'Edit the deck name, description, and questions.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _editDeck,
icon: const Icon(Icons.edit),
label: const Text('Edit Deck'),
),
),
],
),
),
),
const SizedBox(height: 24),
// Reset Deck Section
Card(
color: Theme.of(context).colorScheme.errorContainer.withValues(alpha: 0.1),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.warning_amber_rounded,
color: Theme.of(context).colorScheme.error,
size: 20,
),
const SizedBox(width: 8),
Text(
'Danger Zone',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
Text(
'Reset this deck to erase all progress, attempt history, and statistics. This action cannot be undone.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _resetDeck,
icon: const Icon(Icons.refresh),
label: const Text('Reset Deck'),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
side: BorderSide(color: Theme.of(context).colorScheme.error),
),
),
),
],
),
),
),
],
),
),
),
);
}
}

Powered by TurnKey Linux.