feat(learn): spelling and grammar checked as people write (ADR 0024)

Where the instance's LanguageTool checks the language, the editors
underline what it finds after a pause, a paragraph at a time, a tap
offering its suggestions (GrammarHost, GrammarCheckController,
GrammarOverlay; nothing written into the document). A card's front is
checked in the studyset's front language, its back and answers in the
back's, everything else in the language recognised.

In a private studyset nothing is sent until the person agrees for it,
asked once in a banner above the editor and kept in their settings
(an agreement under privateAgreements, a refusal in grammarDeclined,
settings schema 14), taken back in the studyset's privacy dialog.
Settings > Writing turns it off on the device.

The field's Check asks LanguageTool first where it checks the
studyset's language, in a sheet offering to ask the assistant why;
elsewhere it asks the assistant as before (amends ADR 0021, D54).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-25 23:51:45 +01:00
commit d2eaef255e
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 2986 additions and 32 deletions

View file

@ -0,0 +1,24 @@
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_gateway.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_match.dart';
/// [GrammarGateway] answering from [found], by the text checked: nothing
/// for a text it doesn't hold. Remembers what was checked, in which
/// language; [refusal] refuses every check.
class FakeGrammarGateway({
final Map<String, List<GrammarMatch>> found = const {},
}) implements GrammarGateway {
/// The texts checked, in order, with the language asked for.
final checked = <(String, String?)>[];
/// Why every check is refused; null: none is.
GrammarRefusalReason? refusal;
@override
Future<List<GrammarMatch>> check(String text, {String? language}) async {
checked.add((text, language));
if (refusal case final reason?) {
throw GrammarRefused(reason);
}
return found[text] ?? const [];
}
}

View file

@ -13,6 +13,8 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.
import 'package:halkyon_learn_flutter/src/core/device_settings/data/device_settings_store.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_learn_flutter/src/core/files/file_picker.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_checks.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_gateway.dart';
import 'package:halkyon_learn_flutter/src/core/version/app_version.dart';
import 'package:halkyon_learn_flutter/src/core/video/media_players.dart';
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
@ -90,6 +92,7 @@ import 'fake_explore_gateway.dart';
import 'fake_feedback_gateways.dart';
import 'fake_file_picker.dart';
import 'fake_file_saver.dart';
import 'fake_grammar_gateway.dart';
import 'fake_jellyfin.dart';
import 'fake_learning_path_gateway.dart';
import 'fake_note_gateway.dart';
@ -188,6 +191,9 @@ Dependencies testDependencies({
AssistantOperatorGateway? assistantOperatorGateway,
// A person's own key's provider; none answers unless scripted.
FakeLlmClient? llm,
// The instance checks no writing unless a test names its languages.
GrammarGateway? grammarGateway,
List<String>? grammarLanguages,
}) {
final operators = operatorGateway ?? FakeInstanceOperatorGateway();
final device = deviceDictionaries ?? noDeviceDictionaries();
@ -408,6 +414,10 @@ Dependencies testDependencies({
discussions: DiscussionRepository(
discussionGateway ?? FakeDiscussionGateway(),
),
grammar: GrammarChecks(
grammarGateway ?? FakeGrammarGateway(),
() async => grammarLanguages,
),
onDispose: () async {
await deletions.dispose();
await noteDeletions.dispose();