import 'dart:convert'; import 'package:file_picker/file_picker.dart'; 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 createState() => _DeckImportScreenState(); } class _DeckImportScreenState extends State { final TextEditingController _jsonController = TextEditingController(); String? _errorMessage; bool _isLoading = false; @override void dispose() { _jsonController.dispose(); super.dispose(); } Deck? _parseDeckFromJson(String jsonString) { try { final Map json = jsonDecode(jsonString); // Parse config final configJson = json['config'] as Map? ?? {}; 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, excludeFlaggedQuestions: configJson['excludeFlaggedQuestions'] as bool? ?? false, ); // Parse questions final questionsJson = json['questions'] as List? ?? []; final questions = questionsJson.map((qJson) { final questionMap = qJson as Map; // Support both old format (correctAnswerIndex) and new format (correctAnswerIndices) List? correctIndices; if (questionMap['correctAnswerIndices'] != null) { final indicesJson = questionMap['correctAnswerIndices'] as List?; 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?) ?.map((e) => e.toString()) .toList() ?? [], 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, 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( 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( 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, 'excludeFlaggedQuestions': false, }, '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'); } Future _pickJsonFile() async { final result = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: ['json'], withData: true, ); if (result == null || result.files.isEmpty || !mounted) return; final file = result.files.single; if (file.bytes == null) { if (mounted) { showTopSnackBar(context, message: 'Could not read file content'); } return; } final text = utf8.decode(file.bytes!); setState(() { _errorMessage = null; _jsonController.text = text; }); if (mounted) { showTopSnackBar(context, message: 'File loaded'); } } @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: _pickJsonFile, icon: const Icon(Icons.folder_open), label: const Text('Select file'), ), 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'), const Text('• excludeFlaggedQuestions: 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'), ), ], ); } }