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

580 lines
21 KiB

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:practice_engine/practice_engine.dart';
import '../data/default_deck.dart';
import '../services/deck_storage.dart';
import '../utils/top_snackbar.dart';
class DeckImportScreen extends StatefulWidget {
const DeckImportScreen({super.key});
@override
State<DeckImportScreen> createState() => _DeckImportScreenState();
}
class _DeckImportScreenState extends State<DeckImportScreen> {
final TextEditingController _jsonController = TextEditingController();
String? _errorMessage;
bool _isLoading = false;
@override
void dispose() {
_jsonController.dispose();
super.dispose();
}
Deck? _parseDeckFromJson(String jsonString) {
try {
final Map<String, dynamic> json = jsonDecode(jsonString);
// Parse config
final configJson = json['config'] as Map<String, dynamic>? ?? {};
final config = DeckConfig(
requiredConsecutiveCorrect: configJson['requiredConsecutiveCorrect'] as int? ?? 3,
defaultAttemptSize: configJson['defaultAttemptSize'] as int? ?? 10,
priorityIncreaseOnIncorrect: configJson['priorityIncreaseOnIncorrect'] as int? ?? 5,
priorityDecreaseOnCorrect: configJson['priorityDecreaseOnCorrect'] as int? ?? 2,
immediateFeedbackEnabled: configJson['immediateFeedbackEnabled'] as bool? ?? true,
includeKnownInAttempts: configJson['includeKnownInAttempts'] as bool? ?? false,
shuffleAnswerOrder: configJson['shuffleAnswerOrder'] as bool? ?? true,
);
// Parse questions
final questionsJson = json['questions'] as List<dynamic>? ?? [];
final questions = questionsJson.map((qJson) {
final questionMap = qJson as Map<String, dynamic>;
// Support both old format (correctAnswerIndex) and new format (correctAnswerIndices)
List<int>? correctIndices;
if (questionMap['correctAnswerIndices'] != null) {
final indicesJson = questionMap['correctAnswerIndices'] as List<dynamic>?;
correctIndices = indicesJson?.map((e) => e as int).toList();
} else if (questionMap['correctAnswerIndex'] != null) {
// Backward compatibility: convert single index to list
final singleIndex = questionMap['correctAnswerIndex'] as int?;
if (singleIndex != null) {
correctIndices = [singleIndex];
}
}
return Question(
id: questionMap['id'] as String? ?? '',
prompt: questionMap['prompt'] as String? ?? '',
answers: (questionMap['answers'] as List<dynamic>?)
?.map((e) => e.toString())
.toList() ??
[],
correctAnswerIndices: correctIndices ?? [0],
consecutiveCorrect: questionMap['consecutiveCorrect'] as int? ?? 0,
isKnown: questionMap['isKnown'] as bool? ?? false,
priorityPoints: questionMap['priorityPoints'] as int? ?? 0,
lastAttemptIndex: questionMap['lastAttemptIndex'] as int? ?? -1,
totalCorrectAttempts: questionMap['totalCorrectAttempts'] as int? ?? 0,
totalAttempts: questionMap['totalAttempts'] as int? ?? 0,
);
}).toList();
// Create deck
return Deck(
id: json['id'] as String? ?? DateTime.now().millisecondsSinceEpoch.toString(),
title: json['title'] as String? ?? 'Imported Deck',
description: json['description'] as String? ?? '',
questions: questions,
config: config,
currentAttemptIndex: json['currentAttemptIndex'] as int? ?? 0,
);
} catch (e) {
throw FormatException('Invalid JSON format: $e');
}
}
void _importDeck() {
setState(() {
_errorMessage = null;
_isLoading = true;
});
try {
if (_jsonController.text.trim().isEmpty) {
throw FormatException('Please enter JSON data');
}
final deck = _parseDeckFromJson(_jsonController.text.trim());
if (deck == null) {
throw FormatException('Failed to parse deck');
}
if (deck.questions.isEmpty) {
throw FormatException('Deck must contain at least one question');
}
// Save deck to storage
final deckStorage = DeckStorage();
deckStorage.saveDeckSync(deck);
// Navigate back to deck list
Navigator.pop(context);
} catch (e) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
void _useDefaultDeck() async {
final deckStorage = DeckStorage();
// Always show dialog to ask if user wants mock data
final includeMockData = await showDialog<bool>(
context: context,
builder: (context) => _MockDataDialog(),
);
if (includeMockData == null) {
// User cancelled
return;
}
// Get the appropriate deck version
final defaultDeck = includeMockData
? DefaultDeck.deckWithMockData
: DefaultDeck.deck;
final deckExists = deckStorage.hasDeckSync(DefaultDeck.deck.id);
if (deckExists) {
// Show dialog with options: Replace, Add Copy, or Cancel
final action = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Deck Already Exists'),
content: const Text(
'The General Knowledge Quiz already exists. What would you like to do?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, 'cancel'),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, 'copy'),
child: const Text('Add Copy'),
),
FilledButton(
onPressed: () => Navigator.pop(context, 'replace'),
child: const Text('Replace'),
),
],
),
);
if (action == null || action == 'cancel') {
return;
} else if (action == 'replace') {
deckStorage.saveDeckSync(defaultDeck);
showTopSnackBar(
context,
message: 'Default deck replaced successfully',
backgroundColor: Colors.green,
);
} else if (action == 'copy') {
// Find the next copy number
final allDecks = deckStorage.getAllDecksSync();
final baseTitle = defaultDeck.title;
final copyPattern = RegExp(r'^(.+?)\s*\(Copy\s+(\d+)\)$');
int maxCopyNumber = 0;
for (final deck in allDecks) {
if (deck.title == baseTitle) {
// Original deck exists
continue;
}
final match = copyPattern.firstMatch(deck.title);
if (match != null) {
final titlePart = match.group(1)?.trim();
if (titlePart == baseTitle) {
final copyNum = int.tryParse(match.group(2) ?? '0') ?? 0;
if (copyNum > maxCopyNumber) {
maxCopyNumber = copyNum;
}
}
}
}
final nextCopyNumber = maxCopyNumber + 1;
final copiedDeck = defaultDeck.copyWith(
id: '${defaultDeck.id}_${DateTime.now().millisecondsSinceEpoch}',
title: '$baseTitle (Copy $nextCopyNumber)',
);
deckStorage.saveDeckSync(copiedDeck);
showTopSnackBar(
context,
message: 'Default deck copied successfully',
backgroundColor: Colors.green,
);
}
} else {
// Save default deck to storage
deckStorage.saveDeckSync(defaultDeck);
showTopSnackBar(
context,
message: 'Default deck added successfully',
backgroundColor: Colors.green,
);
}
// Navigate back to deck list
Navigator.pop(context);
}
void _loadSampleDeck() {
final sampleJson = {
'id': 'sample-deck',
'title': 'Sample Practice Deck',
'description': 'This is a sample deck for practicing. It contains various questions to help you learn.',
'config': {
'requiredConsecutiveCorrect': 3,
'defaultAttemptSize': 10,
'priorityIncreaseOnIncorrect': 5,
'priorityDecreaseOnCorrect': 2,
'immediateFeedbackEnabled': true,
'includeKnownInAttempts': false,
'shuffleAnswerOrder': true,
},
'questions': List.generate(20, (i) {
return {
'id': 'q$i',
'prompt': 'Sample Question $i?',
'answers': ['Answer A', 'Answer B', 'Answer C', 'Answer D'],
'correctAnswerIndices': [i % 4],
'isKnown': i < 5,
};
}),
};
_jsonController.text = const JsonEncoder.withIndent(' ').convert(sampleJson);
}
void _copyFieldToClipboard() {
final text = _jsonController.text.trim();
if (text.isEmpty) {
showTopSnackBar(context, message: 'Nothing to copy');
return;
}
Clipboard.setData(ClipboardData(text: text));
showTopSnackBar(context, message: 'JSON copied to clipboard');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Import Deck'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Default Deck Button
Card(
color: Theme.of(context).colorScheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
Icons.quiz,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Start with Default Quiz',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'20 general knowledge questions ready to practice',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.8),
),
),
],
),
),
],
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _useDefaultDeck,
icon: const Icon(Icons.play_arrow),
label: const Text('Use Default Quiz'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
),
),
],
),
),
),
const SizedBox(height: 24),
// Divider
Row(
children: [
Expanded(child: Divider(color: Theme.of(context).colorScheme.outline)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'OR',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
),
Expanded(child: Divider(color: Theme.of(context).colorScheme.outline)),
],
),
const SizedBox(height: 24),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Import Deck from JSON',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Paste your deck JSON below. The format should include id, title, description, config, and questions.',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
const SizedBox(height: 16),
// JSON Input
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Deck JSON',
style: Theme.of(context).textTheme.titleMedium,
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton.icon(
onPressed: _loadSampleDeck,
icon: const Icon(Icons.description),
label: const Text('Load Sample'),
),
IconButton(
onPressed: _copyFieldToClipboard,
icon: const Icon(Icons.copy),
tooltip: 'Copy field contents',
),
],
),
],
),
const SizedBox(height: 8),
TextField(
controller: _jsonController,
maxLines: 15,
decoration: InputDecoration(
hintText: 'Paste JSON here...',
border: const OutlineInputBorder(),
errorText: _errorMessage,
),
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
],
),
),
),
const SizedBox(height: 24),
// Error Message
if (_errorMessage != null)
Card(
color: Colors.red.shade50,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.error, color: Colors.red.shade700),
const SizedBox(width: 12),
Expanded(
child: Text(
_errorMessage!,
style: TextStyle(color: Colors.red.shade700),
),
),
],
),
),
),
if (_errorMessage != null) const SizedBox(height: 16),
// Import Button
FilledButton.icon(
onPressed: _isLoading ? null : _importDeck,
icon: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.upload),
label: Text(_isLoading ? 'Importing...' : 'Import Deck'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
const SizedBox(height: 16),
// JSON Format Help
ExpansionTile(
title: const Text('JSON Format Help'),
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Required fields:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text('• id: string'),
const Text('• title: string'),
const Text('• description: string'),
const Text('• questions: array of question objects'),
const SizedBox(height: 16),
const Text(
'Question object format:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text('• id: string (required)'),
const Text('• prompt: string (required)'),
const Text('• answers: array of strings (required)'),
const Text('• correctAnswerIndex: number (deprecated, use correctAnswerIndices)'),
const Text('• correctAnswerIndices: array of numbers (for multiple correct answers)'),
const Text('• isKnown: boolean (optional)'),
const Text('• consecutiveCorrect: number (optional)'),
const Text('• priorityPoints: number (optional)'),
const SizedBox(height: 16),
const Text(
'Config object (optional):',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text('• requiredConsecutiveCorrect: number'),
const Text('• defaultAttemptSize: number'),
const Text('• priorityIncreaseOnIncorrect: number'),
const Text('• priorityDecreaseOnCorrect: number'),
const Text('• immediateFeedbackEnabled: boolean'),
const Text('• includeKnownInAttempts: boolean'),
const Text('• shuffleAnswerOrder: boolean'),
],
),
),
],
),
],
),
),
);
}
}
class _MockDataDialog extends StatefulWidget {
@override
State<_MockDataDialog> createState() => _MockDataDialogState();
}
class _MockDataDialogState extends State<_MockDataDialog> {
bool _includeMockData = false;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Add Default Deck'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Would you like to include mock attempt history and progress data?',
style: TextStyle(fontSize: 14),
),
const SizedBox(height: 16),
CheckboxListTile(
value: _includeMockData,
onChanged: (value) {
setState(() {
_includeMockData = value ?? false;
});
},
title: const Text('Include mock data'),
subtitle: const Text(
'Adds 4 sample attempts showing progress over time',
style: TextStyle(fontSize: 12),
),
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, null),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, _includeMockData),
child: const Text('Add Deck'),
),
],
);
}
}

Powered by TurnKey Linux.