cyberhybridhub/lib/screens/spelling_list_screen.dart
2026-06-07 03:10:21 +00:00

400 lines
11 KiB
Dart

import 'dart:convert';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import '../data/remote/spelling_words_remote_store.dart';
import '../theme/app_theme.dart';
import '../utils/file_saver.dart';
/// Lists and manages canonical spelling words in the shared catalog.
class SpellingListScreen extends StatefulWidget {
const SpellingListScreen({
super.key,
this.api,
});
final SpellingWordsRemoteStore? api;
@override
State<SpellingListScreen> createState() => _SpellingListScreenState();
}
class _SpellingListScreenState extends State<SpellingListScreen> {
late final SpellingWordsRemoteStore _api;
List<String> _words = <String>[];
bool _loading = true;
String? _error;
bool _busy = false;
@override
void initState() {
super.initState();
_api = widget.api ?? SpellingWordsRemoteStore();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final List<String> 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<void> _runBusy(Future<void> Function() action) async {
if (_busy) {
return;
}
setState(() => _busy = true);
try {
await action();
} finally {
if (mounted) {
setState(() => _busy = false);
}
}
}
Future<void> _onNew() async {
final String? word = await showDialog<String>(
context: context,
builder: (BuildContext context) => const _NewWordDialog(),
);
if (word == null || word.trim().isEmpty) {
return;
}
await _runBusy(() async {
try {
final List<String> 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.');
}
});
}
void _onUpload() {
FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: <String>['txt'],
withData: true,
).then((FilePickerResult? result) {
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;
}
_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.');
}
});
}).catchError((dynamic _) {
_showSnack('Could not open file picker.');
});
}
Future<void> _onDownload() async {
await _runBusy(() async {
try {
final String text = await _api.exportText();
final Uint8List bytes = Uint8List.fromList(utf8.encode(text));
if (kIsWeb) {
await saveFileBytes(bytes, 'spelling-words.txt');
_showSnack('Saved spelling word list');
} else {
final String? path = await FilePicker.saveFile(
dialogTitle: 'Save spelling words',
fileName: 'spelling-words.txt',
type: FileType.custom,
allowedExtensions: <String>['txt'],
bytes: bytes,
);
if (!mounted) {
return;
}
if (path != null) {
_showSnack('Saved spelling word list');
}
}
} catch (_) {
_showSnack('Download failed.');
}
});
}
String? _readFileText(PlatformFile file) {
final List<int>? bytes = file.bytes;
if (bytes != null) {
return utf8.decode(bytes);
}
return null;
}
void _showSnack(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
Future<void> _onDelete(String word) async {
final bool? confirmed = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
key: const Key('spelling-word-delete-confirm-dialog'),
title: const Text('Delete spelling word?'),
content: Text(
'This deletes "$word" from the spelling catalog, along with '
'all linked quiz history and progress. You cannot undo this.',
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
key: const Key('spelling-word-delete-confirm-button'),
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
);
},
);
if (confirmed != true || !mounted) {
return;
}
await _runBusy(() async {
try {
final List<String> words = await _api.deleteWord(word);
if (!mounted) {
return;
}
setState(() => _words = words);
_showSnack('Deleted "$word"');
} catch (_) {
_showSnack('Could not delete word.');
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Spelling words'),
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
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: <Widget>[
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,
),
),
trailing: IconButton(
key: Key('spelling-word-delete-$word'),
icon: const Icon(Icons.delete_outline, color: AppColors.error),
tooltip: 'Delete word',
onPressed: _busy ? null : () => _onDelete(word),
),
);
},
),
);
}
}
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: <Widget>[
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);
}
}