400 lines
11 KiB
Dart
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);
|
|
}
|
|
}
|