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

View file

@ -7,6 +7,7 @@ import 'package:halkyon_app/testing.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_match.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart';
@ -31,6 +32,7 @@ import '../../support/fake_asset_sealing.dart';
import '../../support/fake_asset_server_gateway.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_conversation_gateway.dart';
import '../../support/fake_grammar_gateway.dart';
import '../../support/fake_settings_gateway.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/memory_asset_file_cache.dart';
@ -118,6 +120,7 @@ void main() {
late List<Uri> searched;
late FakeAssetGateway assetGateway;
late MemoryAssetFileCache assetFiles;
late FakeGrammarGateway grammar;
setUp(() {
assistants = FakeAssistantGateway()
@ -141,12 +144,29 @@ void main() {
searched = [];
assetGateway = FakeAssetGateway();
assetFiles = MemoryAssetFileCache();
grammar = FakeGrammarGateway(
found: {
'私はりんごは食べる': const [
GrammarMatch(
offset: 5,
length: 1,
message: 'Use を for the object.',
shortMessage: 'Particle',
replacements: ['を', 'も'],
),
],
},
);
});
Future<void> showApp(
WidgetTester tester, {
bool web = false,
String location = RoutePaths.home,
/// The languages the instance's LanguageTool checks (ADR 0024); none
/// without.
List<String>? grammarLanguages,
}) async {
tester.view.physicalSize = const Size(400, 900);
tester.view.devicePixelRatio = 1;
@ -173,6 +193,8 @@ void main() {
),
deviceSettings: initial,
deviceSettingsStore: store,
grammarGateway: grammar,
grammarLanguages: grammarLanguages,
),
location: location,
);
@ -602,6 +624,55 @@ void main() {
expect(lastSettings().privateAgreementsOf('diary'), {'assistant:gone'});
});
testWidgets("the studyset's privacy says whether what is written in it is "
'checked, and takes the answer back, so it is asked again (ADR 0024)', (
tester,
) async {
settings.receiveRemoteRow(
'settings-grammar',
encodeSettingsPayload(
const PersonSettings().withGrammarAnswer('diary', check: true),
),
);
await showApp(tester, location: RoutePaths.library);
await tester.tap(find.byTooltip('Private'));
await tester.pumpAndSettle();
expect(find.text('Assistants it may go to'), findsNothing);
expect(find.text('Spelling and grammar'), findsOneWidget);
expect(
find.text('Checked by learn.example.org as you write'),
findsOneWidget,
);
await tester.tap(find.text('Take back'));
await tester.pumpAndSettle();
expect(find.text('Spelling and grammar'), findsNothing);
expect(lastSettings().grammarAnswerOf('diary'), isNull);
});
testWidgets("the studyset's privacy says a declined check so, beside the "
'assistants (ADR 0024)', (tester) async {
settings.receiveRemoteRow(
'settings-grammar',
encodeSettingsPayload(
const PersonSettings(
privateAgreements: {
'diary': {'assistant:claude'},
},
).withGrammarAnswer('diary', check: false),
),
);
await showApp(tester, location: RoutePaths.library);
await tester.tap(find.byTooltip('Private'));
await tester.pumpAndSettle();
expect(find.text('Claude'), findsOneWidget);
expect(find.text('Not checked as you write'), findsOneWidget);
expect(find.text('Take back'), findsNWidgets(2));
});
testWidgets('a follow-up sends the conversation again, so it asks again '
'once the agreement was taken back (D17)', (tester) async {
final conversations = ConversationRepository(kept);
@ -825,6 +896,136 @@ void main() {
await tester.pumpAndSettle();
expect(find.byTooltip('Check for mistakes'), findsNothing);
});
/// Writes [text] into the field and checks it.
Future<void> check(WidgetTester tester, String text) async {
await tester.enterText(
find.descendant(
of: find.byType(AssistantField),
matching: find.byType(TextField),
),
text,
);
await tester.pump();
await tester.tap(find.byTooltip('Check for mistakes'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
String fieldText(WidgetTester tester) => tester
.widget<TextField>(
find.descendant(
of: find.byType(AssistantField),
matching: find.byType(TextField),
),
)
.controller!
.text;
testWidgets("Check goes to the instance's LanguageTool first where it "
"checks the studyset's language (ADR 0024): what it found, a "
'suggestion taken into the field, and the assistant asked why only '
'when asked, about the text as checked', (tester) async {
await showApp(
tester,
location: RoutePaths.studyset('japanese'),
grammarLanguages: ['ja', 'de'],
);
assistants.answers.add(_says('を marks the object.'));
await unfold(tester);
await check(tester, '私はりんごは食べる');
expect(grammar.checked, [('私はりんごは食べる', 'ja')]);
expect(assistants.asked, isEmpty);
expect(find.text('Spelling and grammar'), findsOneWidget);
expect(find.text('Particle'), findsOneWidget);
expect(find.text('Use を for the object.'), findsOneWidget);
await tester.tap(find.widgetWithText(ActionChip, 'を'));
await tester.pumpAndSettle();
expect(fieldText(tester), '私はりんごを食べる');
expect(find.text('Particle'), findsNothing, reason: 'taken');
expect(
find.text('Ask Claude why'),
findsNothing,
reason: 'nothing left to explain',
);
// The sheet's, over the field's own.
await tester.tap(find.byTooltip('Close').last);
await tester.pumpAndSettle();
grammar.checked.clear();
await check(tester, '私はりんごは食べる');
await tester.tap(find.text('Ask Claude why'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
final (_, request) = assistants.asked.single;
expect(request.system, contains('check what they wrote'));
expect(_asked(request), '私はりんごは食べる');
expect(
find.text('を marks the object.', findRichText: true),
findsOneWidget,
);
});
testWidgets('where the instance checks the language, Check is there for '
'the web search too, with no assistant to ask', (tester) async {
assistants.offer = AssistantsOffer.none;
await showApp(
tester,
web: true,
location: RoutePaths.studyset('japanese'),
grammarLanguages: ['ja'],
);
await unfold(tester, tooltip: 'Search the web');
await check(tester, '私はりんごは食べる');
expect(grammar.checked, hasLength(1));
expect(find.text('Particle'), findsOneWidget);
expect(find.textContaining('Ask '), findsNothing);
});
testWidgets('where the instance checks other languages only, Check goes '
'to the assistant as before', (tester) async {
await showApp(
tester,
location: RoutePaths.studyset('japanese'),
grammarLanguages: ['de'],
);
assistants.answers.add(_says('One mistake.'));
await unfold(tester);
await check(tester, '私はりんごは食べる');
expect(grammar.checked, isEmpty);
expect(assistants.asked, hasLength(1));
});
testWidgets("where the instance's check fails, the sheet says so and "
'offers to have the assistant check instead', (tester) async {
grammar.refusal = GrammarRefusalReason.tooOften;
await showApp(
tester,
location: RoutePaths.studyset('japanese'),
grammarLanguages: ['ja'],
);
assistants.answers.add(_says('One mistake.'));
await unfold(tester);
await check(tester, '私はりんごは食べる');
expect(
find.text('Checked too often for now. Try again in a minute.'),
findsOneWidget,
);
await tester.tap(find.text('Ask Claude to check'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(_asked(assistants.asked.single.$2), '私はりんごは食べる');
});
});
group('images (D12)', () {
/// Writes a card into Verbs with a picture of a bowl on its front, as