Skip to content

Commit a6e9a65

Browse files
feat: add "add to dictionary" feature
ui: use default icon button padding export result to allow custom language check service implementations
1 parent 3708476 commit a6e9a65

File tree

5 files changed

+221
-29
lines changed

5 files changed

+221
-29
lines changed

example/lib/main.dart

Lines changed: 139 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,55 +20,170 @@ class App extends StatefulWidget {
2020
}
2121

2222
class _AppState extends State<App> {
23-
/// Initialize LanguageToolController
24-
final LanguageToolController _controller = LanguageToolController();
23+
Set<String> _dictionary = {};
24+
final _addWordController = TextEditingController();
2525

26-
static const List<MainAxisAlignment> alignments = [
27-
MainAxisAlignment.center,
28-
MainAxisAlignment.start,
29-
MainAxisAlignment.end,
30-
];
31-
int currentAlignmentIndex = 0;
26+
LanguageToolController? _spellCheckController;
27+
28+
LanguageToolController _nonNullController() {
29+
return _spellCheckController ??= LanguageToolController(
30+
languageCheckService: InMemoryDictionaryLanguageCheckService(
31+
getDictionary: () => _dictionary,
32+
),
33+
);
34+
}
3235

3336
@override
3437
Widget build(BuildContext context) {
38+
final spellCheckController = _nonNullController();
39+
3540
return Material(
3641
child: Scaffold(
3742
body: Column(
38-
mainAxisAlignment: alignments[currentAlignmentIndex],
43+
mainAxisAlignment: MainAxisAlignment.start,
3944
children: [
4045
LanguageToolTextField(
41-
controller: _controller,
46+
controller: spellCheckController,
4247
language: 'en-US',
48+
mistakePopup: MistakePopup(
49+
popupRenderer: PopupOverlayRenderer(),
50+
mistakeBuilder: _mistakeBuilder,
51+
),
4352
),
4453
ValueListenableBuilder(
45-
valueListenable: _controller,
54+
valueListenable: spellCheckController,
4655
builder: (_, __, ___) => CheckboxListTile(
4756
title: const Text("Enable spell checking"),
48-
value: _controller.isEnabled,
49-
onChanged: (value) => _controller.isEnabled = value ?? false,
57+
value: spellCheckController.isEnabled,
58+
onChanged: (value) =>
59+
spellCheckController.isEnabled = value ?? false,
5060
),
5161
),
52-
DropdownMenu(
53-
hintText: "Select alignment...",
54-
onSelected: (value) => setState(() {
55-
currentAlignmentIndex = value ?? 0;
56-
}),
57-
dropdownMenuEntries: const [
58-
DropdownMenuEntry(value: 0, label: "Center alignment"),
59-
DropdownMenuEntry(value: 1, label: "Top alignment"),
60-
DropdownMenuEntry(value: 2, label: "Bottom alignment"),
61-
],
62+
const SizedBox(height: 20),
63+
Card(
64+
margin: const EdgeInsets.all(16),
65+
child: Padding(
66+
padding: const EdgeInsets.all(16),
67+
child: Column(
68+
mainAxisSize: MainAxisSize.min,
69+
crossAxisAlignment: CrossAxisAlignment.start,
70+
children: [
71+
const Text(
72+
'Dictionary',
73+
style: TextStyle(
74+
fontSize: 18,
75+
fontWeight: FontWeight.bold,
76+
),
77+
),
78+
const SizedBox(height: 16),
79+
Row(
80+
children: [
81+
Expanded(
82+
child: TextField(
83+
controller: _addWordController,
84+
decoration: const InputDecoration(
85+
labelText: 'Add word to dictionary',
86+
border: OutlineInputBorder(),
87+
),
88+
onSubmitted: (_) => _addWord(),
89+
),
90+
),
91+
const SizedBox(width: 8),
92+
ElevatedButton(
93+
onPressed: _addWord,
94+
child: const Text('Add'),
95+
),
96+
],
97+
),
98+
const SizedBox(height: 16),
99+
Row(
100+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
101+
children: [
102+
Text(
103+
'Dictionary Words (${_dictionary.length})',
104+
style: const TextStyle(fontWeight: FontWeight.w500),
105+
),
106+
if (_dictionary.isNotEmpty)
107+
TextButton(
108+
onPressed: _clearAllWords,
109+
child: const Text('Clear All'),
110+
),
111+
],
112+
),
113+
const SizedBox(height: 8),
114+
if (_dictionary.isEmpty)
115+
const Center(
116+
child: Text(
117+
'No words in dictionary',
118+
style: TextStyle(color: Colors.grey),
119+
),
120+
)
121+
else
122+
for (final word in _dictionary)
123+
ListTile(
124+
title: Text(word),
125+
trailing: IconButton(
126+
icon: const Icon(Icons.delete),
127+
onPressed: () => _removeWord(word),
128+
),
129+
),
130+
],
131+
),
132+
),
62133
),
63134
],
64135
),
65136
),
66137
);
67138
}
68139

140+
void _addWord() {
141+
final word = _addWordController.text.trim();
142+
143+
if (word.isNotEmpty && !_dictionary.contains(word)) {
144+
setState(() {
145+
_dictionary = {..._dictionary, word};
146+
_addWordController.clear();
147+
_spellCheckController?.recheckText();
148+
});
149+
}
150+
}
151+
152+
void _removeWord(String word) {
153+
setState(() {
154+
_dictionary = _dictionary.difference({word});
155+
_spellCheckController?.recheckText();
156+
});
157+
}
158+
159+
void _clearAllWords() {
160+
setState(() {
161+
_dictionary = {};
162+
_spellCheckController?.recheckText();
163+
});
164+
}
165+
166+
Widget _mistakeBuilder({
167+
required LanguageToolController controller,
168+
required Mistake mistake,
169+
required Offset mistakePosition,
170+
required PopupOverlayRenderer popupRenderer,
171+
}) {
172+
return LanguageToolMistakePopup(
173+
popupRenderer: popupRenderer,
174+
mistake: mistake,
175+
mistakePosition: mistakePosition,
176+
controller: controller,
177+
addWordToDictionary: (word) async {
178+
setState(() => _dictionary = {..._dictionary, word});
179+
},
180+
);
181+
}
182+
69183
@override
70184
void dispose() {
71-
_controller.dispose();
185+
_spellCheckController?.dispose();
186+
_addWordController.dispose();
72187
super.dispose();
73188
}
74189
}

lib/languagetool_textfield.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,7 @@ export 'src/language_check_services/language_tool_service.dart';
1616
export 'src/presentation/language_tool_text_field.dart';
1717
export 'src/utils/mistake_popup.dart';
1818
export 'src/utils/popup_overlay_renderer.dart';
19+
export 'src/utils/result.dart';
1920
export 'src/wrappers/debounce_language_check_service.dart';
21+
export 'src/wrappers/in_memory_dictionary_language_check_service.dart';
2022
export 'src/wrappers/throttling_language_check_service.dart';

lib/src/core/controllers/language_tool_controller.dart

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ class LanguageToolController extends TextEditingController {
5959
_isEnabled = value;
6060

6161
if (_isEnabled) {
62-
_handleTextChange(text, spellCheckSameText: true);
62+
recheckText();
6363
} else {
6464
_mistakes = [];
6565
for (final recognizer in _recognizers) {
@@ -182,6 +182,16 @@ class LanguageToolController extends TextEditingController {
182182
});
183183
}
184184

185+
/// Rechecks the current text for spelling and grammar errors.
186+
///
187+
/// This method forces a recheck of the existing text
188+
/// This is useful when you want to re-evaluate the text without any actual
189+
/// text changes, such as after changing language settings or updating
190+
/// spell check configurations.
191+
void recheckText() {
192+
_handleTextChange(text, spellCheckSameText: true);
193+
}
194+
185195
/// Clear mistakes list when text mas modified and get a new list of mistakes
186196
/// via API
187197
Future<void> _handleTextChange(

lib/src/utils/mistake_popup.dart

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ class LanguageToolMistakePopup extends StatelessWidget {
9696
final ButtonStyle? mistakeStyle;
9797

9898
/// Optional builder that adds additional actions to the header.
99-
final List<Widget> Function(BuildContext context)? actionsBuilder;
99+
final Future<void> Function(String)? addWordToDictionary;
100100

101101
/// Creates a [LanguageToolMistakePopup].
102102
const LanguageToolMistakePopup({
@@ -109,7 +109,7 @@ class LanguageToolMistakePopup extends StatelessWidget {
109109
this.horizontalMargin = _defaultHorizontalMargin,
110110
this.verticalMargin = _defaultVerticalMargin,
111111
this.mistakeStyle,
112-
this.actionsBuilder,
112+
this.addWordToDictionary,
113113
super.key,
114114
});
115115

@@ -173,11 +173,25 @@ class LanguageToolMistakePopup extends StatelessWidget {
173173
],
174174
),
175175
),
176-
...?actionsBuilder?.call(context),
176+
if (addWordToDictionary case final addWordToDictionary?)
177+
IconButton(
178+
icon: const Icon(Icons.menu_book),
179+
constraints: const BoxConstraints(),
180+
splashRadius: _dismissSplashRadius,
181+
onPressed: () async {
182+
final word = controller.text.substring(
183+
mistake.offset,
184+
mistake.endOffset,
185+
);
186+
187+
await addWordToDictionary(word);
188+
189+
_fixTheMistake(word);
190+
},
191+
),
177192
IconButton(
178193
icon: const Icon(Icons.close),
179194
constraints: const BoxConstraints(),
180-
padding: EdgeInsets.zero,
181195
splashRadius: _dismissSplashRadius,
182196
onPressed: () {
183197
_dismissDialog();
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import 'package:languagetool_textfield/languagetool_textfield.dart';
2+
3+
/// A language-check service that filters LanguageTool suggestions using an in-memory dictionary.
4+
///
5+
/// This class wraps a LanguageToolService and extends ThrottlingLanguageCheckService to
6+
/// limit the frequency of requests. After performing a check with the underlying service,
7+
/// it removes any reported mistakes whose corresponding word is present in the provided
8+
/// in-memory dictionary (so user-defined or domain-specific words can be treated as correct).
9+
///
10+
/// The filtering is performed by extracting the substring of the input text using each
11+
/// mistake's offset and endOffset and checking membership against the dictionary returned
12+
/// by [getDictionary].
13+
///
14+
/// Note: the underlying service is throttled to avoid excessive requests; the throttling
15+
/// behavior is provided by the superclass.
16+
class InMemoryDictionaryLanguageCheckService
17+
extends ThrottlingLanguageCheckService {
18+
/// Callback that supplies the current set of words to be treated as correct.
19+
///
20+
/// This function is invoked for each text check so the dictionary can be dynamic
21+
/// (for example, reflecting user edits or settings). It must return a Set<String>
22+
/// containing the words that should be ignored by the language checker.
23+
final Set<String> Function() getDictionary;
24+
25+
/// Creates an InMemoryDictionaryLanguageCheckService that uses [getDictionary] to filter mistakes.
26+
///
27+
/// The [getDictionary] callback is required and will be called for every check operation.
28+
/// The service delegates checking to an internal LanguageToolService and then filters
29+
/// the results based on the returned dictionary.
30+
InMemoryDictionaryLanguageCheckService({required this.getDictionary})
31+
: super(
32+
LanguageToolService(LanguageToolClient()),
33+
const Duration(milliseconds: 250),
34+
);
35+
36+
@override
37+
Future<Result<List<Mistake>>?> findMistakes(String text) async {
38+
final result = await super.findMistakes(text);
39+
final dictionary = getDictionary();
40+
41+
return result?.map(
42+
(mistakes) => mistakes.where(
43+
(mistake) {
44+
final word = text.substring(mistake.offset, mistake.endOffset);
45+
46+
return !dictionary.contains(word);
47+
},
48+
).toList(),
49+
);
50+
}
51+
}

0 commit comments

Comments
 (0)