import 'dart:convert'; import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import '../data/remote/spelling_words_remote_store.dart'; import '../theme/app_theme.dart'; /// Lists and manages canonical spelling words in the shared catalog. class SpellingListScreen extends StatefulWidget { const SpellingListScreen({super.key}); @override State createState() => _SpellingListScreenState(); } class _SpellingListScreenState extends State { final SpellingWordsRemoteStore _api = SpellingWordsRemoteStore(); List _words = []; bool _loading = true; String? _error; bool _busy = false; @override void initState() { super.initState(); _load(); } Future _load() async { setState(() { _loading = true; _error = null; }); try { final List words = await _api.fetchWords(); if (!mounted) { return; } setState(() { _words = words; _loading = false; }); } catch (_) { if (!mounted) { return; } setState(() { _loading = false; _error = 'Could not load spelling words.'; }); } } Future _runBusy(Future Function() action) async { if (_busy) { return; } setState(() => _busy = true); try { await action(); } finally { if (mounted) { setState(() => _busy = false); } } } Future _onNew() async { final String? word = await showDialog( context: context, builder: (BuildContext context) => const _NewWordDialog(), ); if (word == null || word.trim().isEmpty) { return; } await _runBusy(() async { try { final List words = await _api.addWord(word); if (!mounted) { return; } setState(() => _words = words); _showSnack('Added "${word.trim().toLowerCase()}"'); } on SpellingWordsApiException catch (e) { if (e.statusCode == 409) { _showSnack('That word is already in the list.'); } else { _showSnack('Could not add word.'); } } catch (_) { _showSnack('Could not add word.'); } }); } Future _onUpload() async { final FilePickerResult? result = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: ['txt'], withData: true, ); if (result == null || result.files.isEmpty) { return; } final PlatformFile file = result.files.first; final String? text = _readFileText(file); if (text == null || text.trim().isEmpty) { _showSnack('The file is empty or could not be read.'); return; } await _runBusy(() async { try { final ({int added, int skipped}) stats = await _api.importText(text); await _load(); if (!mounted) { return; } _showSnack('Upload complete: ${stats.added} added, ${stats.skipped} skipped'); } catch (_) { _showSnack('Upload failed.'); } }); } Future _onDownload() async { await _runBusy(() async { try { final String text = await _api.exportText(); final Uint8List bytes = Uint8List.fromList(utf8.encode(text)); final String? path = await FilePicker.platform.saveFile( dialogTitle: 'Save spelling words', fileName: 'spelling-words.txt', type: FileType.custom, allowedExtensions: ['txt'], bytes: bytes, ); if (!mounted) { return; } if (path == null) { return; } _showSnack('Saved spelling word list'); } catch (_) { _showSnack('Download failed.'); } }); } String? _readFileText(PlatformFile file) { final List? bytes = file.bytes; if (bytes != null) { return utf8.decode(bytes); } return null; } void _showSnack(String message) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message)), ); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Spelling words'), ), body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Wrap( spacing: 8, runSpacing: 8, children: [ FilledButton.icon( key: const Key('spelling-words-new'), onPressed: _busy ? null : _onNew, icon: const Icon(Icons.add, size: 18), label: const Text('New'), ), OutlinedButton.icon( key: const Key('spelling-words-upload'), onPressed: _busy ? null : _onUpload, icon: const Icon(Icons.upload_file, size: 18), label: const Text('Upload'), ), OutlinedButton.icon( key: const Key('spelling-words-download'), onPressed: _busy ? null : _onDownload, icon: const Icon(Icons.download, size: 18), label: const Text('Download'), ), if (_busy) const Padding( padding: EdgeInsets.all(8), child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ), ), ], ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( 'One word per line in upload/download files. Download includes canonical words only (no quiz history).', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: AppColors.textSecondary, ), ), ), const SizedBox(height: 8), Expanded(child: _buildBody()), ], ), ); } Widget _buildBody() { if (_loading) { return const Center( child: CircularProgressIndicator(color: AppColors.accent), ); } if (_error != null) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(_error!, style: const TextStyle(color: Colors.redAccent)), const SizedBox(height: 12), FilledButton(onPressed: _load, child: const Text('Retry')), ], ), ); } if (_words.isEmpty) { return const Center( child: Text( 'No spelling words yet. Add one or upload a text file.', style: TextStyle(color: AppColors.textSecondary), ), ); } return RefreshIndicator( onRefresh: _load, color: AppColors.accent, child: ListView.separated( padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), itemCount: _words.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (BuildContext context, int index) { final String word = _words[index]; return ListTile( key: Key('spelling-word-$word'), title: Text( word, style: const TextStyle( color: AppColors.textPrimary, fontWeight: FontWeight.w500, ), ), ); }, ), ); } } class _NewWordDialog extends StatefulWidget { const _NewWordDialog(); @override State<_NewWordDialog> createState() => _NewWordDialogState(); } class _NewWordDialogState extends State<_NewWordDialog> { final TextEditingController _controller = TextEditingController(); @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AlertDialog( title: const Text('New spelling word'), content: TextField( key: const Key('spelling-new-word-field'), controller: _controller, autofocus: true, decoration: const InputDecoration( hintText: 'Enter a word', border: OutlineInputBorder(), ), textInputAction: TextInputAction.done, onSubmitted: (_) => _submit(), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), FilledButton( key: const Key('spelling-new-word-add'), onPressed: _submit, child: const Text('Add'), ), ], ); } void _submit() { final String word = _controller.text.trim(); if (word.isEmpty) { return; } Navigator.of(context).pop(word); } }