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
32 changed files with 2986 additions and 32 deletions

View file

@ -587,6 +587,10 @@
"settingsStudy": "Lernen",
"settingsStudyPomodoroTitle": "Pomodoro-Timer beim Lernen anzeigen",
"settingsStudyPomodoroSubtitle": "Zeige den Fokus-/Pausen-Countdown in der Lernsitzung, damit du die verbleibende Zeit siehst, ohne die Benachrichtigung zu öffnen.",
"settingsWriting": "Schreiben",
"settingsGrammarTitle": "Grammatik beim Schreiben prüfen",
"settingsGrammarSubtitle": "Unterstreiche mögliche Rechtschreib- und Grammatikfehler in Karteikarten und Notizen, mit Korrekturen per Fingertipp. Die Prüfung läuft, wenn du pausierst, in den Sprachen des Lernsets.",
"settingsGrammarUnavailable": "Diese Instanz hat noch keine Rechtschreib- und Grammatikprüfung eingerichtet, daher wird nichts unterstrichen. Bleibt es an, beginnt die Prüfung, sobald sie eine hat.",
"settingsVersion": "Version {version} (Build {build})",
"syncTitle": "Synchronisierung",
"syncStateSyncing": "Wird synchronisiert…",
@ -1880,12 +1884,27 @@
"assistantPrivateMark": "{name} darf dieses private Lernset bekommen",
"studysetPrivacyAssistants": "Assistenten, an die es gehen darf",
"studysetPrivacyTakeBack": "Zurücknehmen",
"studysetPrivacyGrammar": "Rechtschreibung und Grammatik",
"studysetPrivacyGrammarChecked": "Beim Schreiben von {instance} geprüft",
"studysetPrivacyGrammarNotChecked": "Beim Schreiben nicht geprüft",
"studyMissedExplain": "Nicht gewusst? {name} kann erklären, warum.",
"assistantExplain": "Erklären",
"assistantExplainQuestion": "Warum ist das die Antwort?",
"studyTypedWhy": "Warum?",
"assistantWhyTyped": "Ich habe „{typed}“ getippt. Warum stimmt das nicht?",
"assistantCheck": "Auf Fehler prüfen",
"grammarNoSuggestions": "Keine Vorschläge",
"grammarNothingFound": "Keine Rechtschreib- oder Grammatikfehler gefunden",
"grammarPrivateAsk": "Rechtschreibung und Grammatik in diesem privaten Lernset prüfen? Während geprüft wird, geht, was du hier schreibst, lesbar an {instance}, ein Absatz nach dem anderen; weder die Instanz noch ihr LanguageTool behält etwas davon. Gefragt wird einmal für dieses Lernset und seine Themen.",
"grammarPrivateNotHere": "Hier nicht",
"grammarPrivateCheckHere": "Hier prüfen",
"grammarCheckTitle": "Rechtschreibung und Grammatik",
"grammarCheckTooOften": "Gerade zu oft geprüft. Versuche es in einer Minute erneut.",
"grammarCheckTooLong": "Zu lang, um es auf einmal zu prüfen.",
"grammarCheckUnknownLanguage": "Diese Sprache prüft die Instanz nicht.",
"grammarCheckFailed": "Die Prüfung der Instanz ist nicht erreichbar. Versuche es später erneut.",
"grammarCheckAskWhy": "{name} fragen, warum",
"grammarCheckAskInstead": "{name} prüfen lassen",
"proposalsTitle": "Karteikarten von {name}",
"proposalsWriting": "{name} schreibt Karteikarten …",
"proposalsNone": "{name} hat keine Karteikarten vorgeschlagen.",

View file

@ -666,6 +666,12 @@
"settingsStudy": "Study",
"settingsStudyPomodoroTitle": "Show the Pomodoro timer while studying",
"settingsStudyPomodoroSubtitle": "Show the focus / break countdown inside the study session, so you can see the time left without opening the notification.",
"settingsWriting": "Writing",
"@settingsWriting": {"description": "Settings' card on checking what is written (ADR 0024)."},
"settingsGrammarTitle": "Check grammar while writing",
"settingsGrammarSubtitle": "Underline possible spelling and grammar mistakes in flashcards and notes, with one-tap fixes. Checks run when you pause, in the studyset's languages.",
"settingsGrammarUnavailable": "This instance has no spelling and grammar check set up yet, so nothing is underlined. Left on, checking starts once it has one.",
"@settingsGrammarUnavailable": {"description": "Below the switch where the instance runs no LanguageTool."},
"settingsVersion": "Version {version} (build {build})",
"@settingsVersion": {"placeholders": {"version": {"type": "String"}, "build": {"type": "String"}}},
"syncTitle": "Sync",
@ -2217,6 +2223,11 @@
"@studysetPrivacyAssistants": {"description": "The privacy dialog's list of the assistants agreed to (D17)."},
"studysetPrivacyTakeBack": "Take back",
"@studysetPrivacyTakeBack": {"description": "Takes an agreement back."},
"studysetPrivacyGrammar": "Spelling and grammar",
"@studysetPrivacyGrammar": {"description": "The privacy dialog's answer on checking what is written (ADR 0024)."},
"studysetPrivacyGrammarChecked": "Checked by {instance} as you write",
"@studysetPrivacyGrammarChecked": {"placeholders": {"instance": {"type": "String"}}},
"studysetPrivacyGrammarNotChecked": "Not checked as you write",
"studyMissedExplain": "Missed it? {name} can explain why.",
"@studyMissedExplain": {"description": "After a card was rated Bad (ADR 0021, D53); name is the assistant's.", "placeholders": {"name": {"type": "String"}}},
"assistantExplain": "Explain",
@ -2229,6 +2240,23 @@
"@assistantWhyTyped": {"description": "The question an assistant is asked about a reading typed wrong.", "placeholders": {"typed": {"type": "String"}}},
"assistantCheck": "Check for mistakes",
"@assistantCheck": {"description": "Has an assistant check what was written in the field (D54)."},
"grammarNoSuggestions": "No suggestions",
"@grammarNoSuggestions": {"description": "In place of suggestions for a mistake that has none."},
"grammarNothingFound": "No spelling or grammar mistakes found",
"grammarPrivateAsk": "Check spelling and grammar in this private studyset? While it is checked, what you write here goes to {instance} readable, a paragraph at a time; neither the instance nor its LanguageTool keeps any of it. You're asked once for this studyset and its topics.",
"@grammarPrivateAsk": {"placeholders": {"instance": {"type": "String"}}, "description": "The banner above an editor in a private studyset not asked yet (ADR 0024)."},
"grammarPrivateNotHere": "Not here",
"grammarPrivateCheckHere": "Check here",
"grammarCheckTitle": "Spelling and grammar",
"@grammarCheckTitle": {"description": "The field's Check sheet, what the instance's LanguageTool found."},
"grammarCheckTooOften": "Checked too often for now. Try again in a minute.",
"grammarCheckTooLong": "Too long to check at once.",
"grammarCheckUnknownLanguage": "The instance doesn't check this language.",
"grammarCheckFailed": "The instance's check couldn't be reached. Try again later.",
"grammarCheckAskWhy": "Ask {name} why",
"@grammarCheckAskWhy": {"placeholders": {"name": {"type": "String"}}},
"grammarCheckAskInstead": "Ask {name} to check",
"@grammarCheckAskInstead": {"placeholders": {"name": {"type": "String"}}},
"proposalsTitle": "Flashcards by {name}",
"@proposalsTitle": {"description": "The sheet of cards an assistant proposes (ADR 0021, D55).", "placeholders": {"name": {"type": "String"}}},
"proposalsWriting": "{name} is writing flashcards…",

View file

@ -3508,6 +3508,30 @@ abstract class AppLocalizations {
/// **'Show the focus / break countdown inside the study session, so you can see the time left without opening the notification.'**
String get settingsStudyPomodoroSubtitle;
/// Settings' card on checking what is written (ADR 0024).
///
/// In en, this message translates to:
/// **'Writing'**
String get settingsWriting;
/// No description provided for @settingsGrammarTitle.
///
/// In en, this message translates to:
/// **'Check grammar while writing'**
String get settingsGrammarTitle;
/// No description provided for @settingsGrammarSubtitle.
///
/// In en, this message translates to:
/// **'Underline possible spelling and grammar mistakes in flashcards and notes, with one-tap fixes. Checks run when you pause, in the studyset\'s languages.'**
String get settingsGrammarSubtitle;
/// Below the switch where the instance runs no LanguageTool.
///
/// In en, this message translates to:
/// **'This instance has no spelling and grammar check set up yet, so nothing is underlined. Left on, checking starts once it has one.'**
String get settingsGrammarUnavailable;
/// No description provided for @settingsVersion.
///
/// In en, this message translates to:
@ -11102,6 +11126,24 @@ abstract class AppLocalizations {
/// **'Take back'**
String get studysetPrivacyTakeBack;
/// The privacy dialog's answer on checking what is written (ADR 0024).
///
/// In en, this message translates to:
/// **'Spelling and grammar'**
String get studysetPrivacyGrammar;
/// No description provided for @studysetPrivacyGrammarChecked.
///
/// In en, this message translates to:
/// **'Checked by {instance} as you write'**
String studysetPrivacyGrammarChecked(String instance);
/// No description provided for @studysetPrivacyGrammarNotChecked.
///
/// In en, this message translates to:
/// **'Not checked as you write'**
String get studysetPrivacyGrammarNotChecked;
/// After a card was rated Bad (ADR 0021, D53); name is the assistant's.
///
/// In en, this message translates to:
@ -11138,6 +11180,78 @@ abstract class AppLocalizations {
/// **'Check for mistakes'**
String get assistantCheck;
/// In place of suggestions for a mistake that has none.
///
/// In en, this message translates to:
/// **'No suggestions'**
String get grammarNoSuggestions;
/// No description provided for @grammarNothingFound.
///
/// In en, this message translates to:
/// **'No spelling or grammar mistakes found'**
String get grammarNothingFound;
/// The banner above an editor in a private studyset not asked yet (ADR 0024).
///
/// In en, this message translates to:
/// **'Check spelling and grammar in this private studyset? While it is checked, what you write here goes to {instance} readable, a paragraph at a time; neither the instance nor its LanguageTool keeps any of it. You\'re asked once for this studyset and its topics.'**
String grammarPrivateAsk(String instance);
/// No description provided for @grammarPrivateNotHere.
///
/// In en, this message translates to:
/// **'Not here'**
String get grammarPrivateNotHere;
/// No description provided for @grammarPrivateCheckHere.
///
/// In en, this message translates to:
/// **'Check here'**
String get grammarPrivateCheckHere;
/// The field's Check sheet, what the instance's LanguageTool found.
///
/// In en, this message translates to:
/// **'Spelling and grammar'**
String get grammarCheckTitle;
/// No description provided for @grammarCheckTooOften.
///
/// In en, this message translates to:
/// **'Checked too often for now. Try again in a minute.'**
String get grammarCheckTooOften;
/// No description provided for @grammarCheckTooLong.
///
/// In en, this message translates to:
/// **'Too long to check at once.'**
String get grammarCheckTooLong;
/// No description provided for @grammarCheckUnknownLanguage.
///
/// In en, this message translates to:
/// **'The instance doesn\'t check this language.'**
String get grammarCheckUnknownLanguage;
/// No description provided for @grammarCheckFailed.
///
/// In en, this message translates to:
/// **'The instance\'s check couldn\'t be reached. Try again later.'**
String get grammarCheckFailed;
/// No description provided for @grammarCheckAskWhy.
///
/// In en, this message translates to:
/// **'Ask {name} why'**
String grammarCheckAskWhy(String name);
/// No description provided for @grammarCheckAskInstead.
///
/// In en, this message translates to:
/// **'Ask {name} to check'**
String grammarCheckAskInstead(String name);
/// The sheet of cards an assistant proposes (ADR 0021, D55).
///
/// In en, this message translates to:

View file

@ -2118,6 +2118,20 @@ class AppLocalizationsDe extends AppLocalizations {
String get settingsStudyPomodoroSubtitle =>
'Zeige den Fokus-/Pausen-Countdown in der Lernsitzung, damit du die verbleibende Zeit siehst, ohne die Benachrichtigung zu öffnen.';
@override
String get settingsWriting => 'Schreiben';
@override
String get settingsGrammarTitle => 'Grammatik beim Schreiben prüfen';
@override
String get settingsGrammarSubtitle =>
'Unterstreiche mögliche Rechtschreib- und Grammatikfehler in Karteikarten und Notizen, mit Korrekturen per Fingertipp. Die Prüfung läuft, wenn du pausierst, in den Sprachen des Lernsets.';
@override
String get settingsGrammarUnavailable =>
'Diese Instanz hat noch keine Rechtschreib- und Grammatikprüfung eingerichtet, daher wird nichts unterstrichen. Bleibt es an, beginnt die Prüfung, sobald sie eine hat.';
@override
String settingsVersion(String version, String build) {
return 'Version $version (Build $build)';
@ -7005,6 +7019,17 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get studysetPrivacyTakeBack => 'Zurücknehmen';
@override
String get studysetPrivacyGrammar => 'Rechtschreibung und Grammatik';
@override
String studysetPrivacyGrammarChecked(String instance) {
return 'Beim Schreiben von $instance geprüft';
}
@override
String get studysetPrivacyGrammarNotChecked => 'Beim Schreiben nicht geprüft';
@override
String studyMissedExplain(String name) {
return 'Nicht gewusst? $name kann erklären, warum.';
@ -7027,6 +7052,52 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get assistantCheck => 'Auf Fehler prüfen';
@override
String get grammarNoSuggestions => 'Keine Vorschläge';
@override
String get grammarNothingFound =>
'Keine Rechtschreib- oder Grammatikfehler gefunden';
@override
String grammarPrivateAsk(String instance) {
return 'Rechtschreibung und Grammatik in diesem privaten Lernset prüfen? Während geprüft wird, geht, was du hier schreibst, lesbar an $instance, ein Absatz nach dem anderen; weder die Instanz noch ihr LanguageTool behält etwas davon. Gefragt wird einmal für dieses Lernset und seine Themen.';
}
@override
String get grammarPrivateNotHere => 'Hier nicht';
@override
String get grammarPrivateCheckHere => 'Hier prüfen';
@override
String get grammarCheckTitle => 'Rechtschreibung und Grammatik';
@override
String get grammarCheckTooOften =>
'Gerade zu oft geprüft. Versuche es in einer Minute erneut.';
@override
String get grammarCheckTooLong => 'Zu lang, um es auf einmal zu prüfen.';
@override
String get grammarCheckUnknownLanguage =>
'Diese Sprache prüft die Instanz nicht.';
@override
String get grammarCheckFailed =>
'Die Prüfung der Instanz ist nicht erreichbar. Versuche es später erneut.';
@override
String grammarCheckAskWhy(String name) {
return '$name fragen, warum';
}
@override
String grammarCheckAskInstead(String name) {
return '$name prüfen lassen';
}
@override
String proposalsTitle(String name) {
return 'Karteikarten von $name';

View file

@ -2098,6 +2098,20 @@ class AppLocalizationsEn extends AppLocalizations {
String get settingsStudyPomodoroSubtitle =>
'Show the focus / break countdown inside the study session, so you can see the time left without opening the notification.';
@override
String get settingsWriting => 'Writing';
@override
String get settingsGrammarTitle => 'Check grammar while writing';
@override
String get settingsGrammarSubtitle =>
'Underline possible spelling and grammar mistakes in flashcards and notes, with one-tap fixes. Checks run when you pause, in the studyset\'s languages.';
@override
String get settingsGrammarUnavailable =>
'This instance has no spelling and grammar check set up yet, so nothing is underlined. Left on, checking starts once it has one.';
@override
String settingsVersion(String version, String build) {
return 'Version $version (build $build)';
@ -6939,6 +6953,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studysetPrivacyTakeBack => 'Take back';
@override
String get studysetPrivacyGrammar => 'Spelling and grammar';
@override
String studysetPrivacyGrammarChecked(String instance) {
return 'Checked by $instance as you write';
}
@override
String get studysetPrivacyGrammarNotChecked => 'Not checked as you write';
@override
String studyMissedExplain(String name) {
return 'Missed it? $name can explain why.';
@ -6961,6 +6986,51 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get assistantCheck => 'Check for mistakes';
@override
String get grammarNoSuggestions => 'No suggestions';
@override
String get grammarNothingFound => 'No spelling or grammar mistakes found';
@override
String grammarPrivateAsk(String instance) {
return 'Check spelling and grammar in this private studyset? While it is checked, what you write here goes to $instance readable, a paragraph at a time; neither the instance nor its LanguageTool keeps any of it. You\'re asked once for this studyset and its topics.';
}
@override
String get grammarPrivateNotHere => 'Not here';
@override
String get grammarPrivateCheckHere => 'Check here';
@override
String get grammarCheckTitle => 'Spelling and grammar';
@override
String get grammarCheckTooOften =>
'Checked too often for now. Try again in a minute.';
@override
String get grammarCheckTooLong => 'Too long to check at once.';
@override
String get grammarCheckUnknownLanguage =>
'The instance doesn\'t check this language.';
@override
String get grammarCheckFailed =>
'The instance\'s check couldn\'t be reached. Try again later.';
@override
String grammarCheckAskWhy(String name) {
return 'Ask $name why';
}
@override
String grammarCheckAskInstead(String name) {
return 'Ask $name to check';
}
@override
String proposalsTitle(String name) {
return 'Flashcards by $name';

View file

@ -14,6 +14,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/privacy/data/content_keys.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy_ledger.dart';
@ -637,6 +639,10 @@ Future<PersonDependencies> _openPersonData(
assets,
),
discussions: discussions,
grammar: GrammarChecks(
ServerpodGrammarGateway(client),
() async => (await client.instance.info()).grammarLanguages,
),
onDispose: () async {
try {
// Deletions first: those still waiting are dropped, those under way

View file

@ -1,6 +1,7 @@
import 'package:flutter/widgets.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_checks.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_lookups.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
@ -120,6 +121,10 @@ class PersonDependencies({
/// Published studysets' discussions, on the instance.
required final DiscussionRepository discussions,
/// Spelling and grammar checked by the instance's LanguageTool (ADR
/// 0024).
required final GrammarChecks grammar,
final Future<void> Function()? _onDispose,
}) implements Disposable {
/// The dependencies of the person signed in above [context]

View file

@ -39,6 +39,9 @@ class DeviceSettingsBloc(
on<DeviceSettingsEventAskedWith>(
(event, emit) => _apply(emit, state.withAskWith(event.key)),
);
on<DeviceSettingsEventCheckGrammarChosen>(
(event, emit) => _apply(emit, state.withCheckGrammar(event.on)),
);
on<DeviceSettingsEventPdfSidebarDragged>(
(event, emit) => _apply(emit, state.withPdfSidebarShare(event.share)),
);
@ -123,6 +126,13 @@ final class const DeviceSettingsEventAskedWith(final String key)
List<Object?> get props => [key];
}
/// The person turned checking writing as it is written [on] or off.
final class const DeviceSettingsEventCheckGrammarChosen(final bool on)
extends DeviceSettingsEvent {
@override
List<Object?> get props => [on];
}
/// The person shows the card browser of [studysetId] one card at a time
/// ([detailed]) or as a grid.
final class const DeviceSettingsEventBrowserViewChosen(

View file

@ -70,6 +70,7 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
final String key => key,
_ => null,
},
checkGrammar: json['checkGrammar'] != false,
);
} on FormatException catch (error) {
log('The device settings are unreadable', name: 'Settings', error: error);
@ -93,6 +94,7 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
'customSearchUrl': ?settings.customSearchUrl,
'pdfSidebarShare': ?settings.pdfSidebarShare,
'askWith': ?settings.askWith,
'checkGrammar': settings.checkGrammar,
}),
flush: true,
);

View file

@ -12,7 +12,8 @@ enum Appearance() {
/// app looks, the language it speaks, the style it cites sources in,
/// whether studying shows the Pomodoro timer, which studysets' card
/// browsers show one card at a time, whether and how the person searches
/// the web from the app, and who answered the field last.
/// the web from the app, who answered the field last, and whether writing
/// is checked as it is written.
/// Unlike the person's settings, they don't follow anyone to other devices,
/// and they apply before anyone is signed in.
final class const DeviceSettings({
@ -56,6 +57,11 @@ final class const DeviceSettings({
/// granted, `own:<id>` for one with the person's own key (ADR 0021);
/// null before the first.
final String? askWith,
/// Whether the editors underline what the instance's LanguageTool finds
/// as the person writes (ADR 0024, Settings › Writing): on unless turned
/// off; nothing is checked where the instance checks nothing.
final bool checkGrammar = true,
}) with Equatable {
/// These settings with [appearance] instead.
DeviceSettings withAppearance(Appearance appearance) =>
@ -97,6 +103,9 @@ final class const DeviceSettings({
/// These settings with [key] having answered last ([askWith]).
DeviceSettings withAskWith(String key) => _copy(askWith: key);
/// These settings with writing checked as it is written [on] or off.
DeviceSettings withCheckGrammar(bool on) => _copy(checkGrammar: on);
DeviceSettings _copy({
Appearance? appearance,
Object? language = _keep,
@ -108,6 +117,7 @@ final class const DeviceSettings({
Object? customSearchUrl = _keep,
double? pdfSidebarShare,
Object? askWith = _keep,
bool? checkGrammar,
}) => DeviceSettings(
appearance: appearance ?? this.appearance,
language: identical(language, _keep) ? this.language : language as String?,
@ -123,6 +133,7 @@ final class const DeviceSettings({
: customSearchUrl as String?,
pdfSidebarShare: pdfSidebarShare ?? this.pdfSidebarShare,
askWith: identical(askWith, _keep) ? this.askWith : askWith as String?,
checkGrammar: checkGrammar ?? this.checkGrammar,
);
@override
@ -137,6 +148,7 @@ final class const DeviceSettings({
customSearchUrl,
pdfSidebarShare,
askWith,
checkGrammar,
];
}

View file

@ -15,6 +15,7 @@ import 'package:halkyon_learn_flutter/src/core/editor/link.dart';
import 'package:halkyon_learn_flutter/src/core/editor/math_block.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_document.dart';
import 'package:halkyon_learn_flutter/src/core/editor/ruby.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_host.dart';
import 'package:material_ui/material_ui.dart';
/// Edits [initialContent] and reports each change as new [RichContent].
@ -25,7 +26,9 @@ import 'package:material_ui/material_ui.dart';
/// left. On desktops, `/` opens the same blocks as Insert ([blocksOf]).
/// Copying and pasting keep the formats between the app's editors
/// ([EditorClipboard]): with the keys on desktops, and on phones from the
/// bar that shows over selected text (Copy, Cut, Paste, Select all).
/// bar that shows over selected text (Copy, Cut, Paste, Select all). With
/// [grammar], spelling and grammar are checked as the text is written, in
/// that language ([GrammarHost], ADR 0024).
class const RichContentEditor({
/// The content the editor opens with; later changes to it are ignored.
required final RichContent initialContent,
@ -44,6 +47,9 @@ class const RichContentEditor({
/// The room around the text, within the editor's scrolling.
final EdgeInsets padding = EdgeInsets.zero,
/// The language the text is checked in as it is written; null: it isn't.
final GrammarLanguage? grammar,
super.key,
}) extends StatefulWidget {
/// Whether an editor's popup (its `/` menu) has the focus for now: it
@ -187,6 +193,15 @@ class _RichContentEditorState() extends State<RichContentEditor> {
: appFlowyEditorAutoScrollEdgeOffset,
),
);
final checked = switch (widget.grammar) {
final language? => GrammarHost(
editorState: _editorState,
focusNode: _focus,
language: language,
child: appFlowyEditor,
),
null => appFlowyEditor,
};
final editor = switch (defaultTargetPlatform) {
TargetPlatform.android || TargetPlatform.iOS => MobileFloatingToolbar(
editorState: _editorState,
@ -217,9 +232,9 @@ class _RichContentEditorState() extends State<RichContentEditor> {
onShare: null,
anchors: TextSelectionToolbarAnchors(primaryAnchor: anchor),
),
child: appFlowyEditor,
child: checked,
),
_ => appFlowyEditor,
_ => checked,
};
if (!widget.formatBar) {
return editor;

View file

@ -4,6 +4,7 @@ import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/asset/widget/studyset_files.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_host.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
@ -87,6 +88,7 @@ class _RichTextEditorState() extends State<_RichTextEditor> {
initialContent: widget.initial,
onChanged: (text) => _text = text,
formatBar: true,
grammar: GrammarLanguage.recognised,
),
),
],

View file

@ -0,0 +1,242 @@
import 'dart:async';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/widgets.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_gateway.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_match.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_segments.dart';
/// What the last check of an editor's text concluded, so that "checked,
/// nothing found" differs from "nothing checked" (both have no
/// [GrammarCheckController.spans]).
enum GrammarCheckStatus() {
/// Nothing to show: nothing checked yet, an edit since, or no text.
idle,
/// A check is under way.
checking,
/// Every paragraph was checked, and nothing found.
clean,
/// Something was found: [GrammarCheckController.spans].
issues,
/// A paragraph couldn't be checked (offline, too often, the instance's
/// LanguageTool away) and nothing was found: not known to be clean.
failed,
}
/// Something found, where it is in the editor's document: within one node.
final class const GrammarSpan({
required final Selection selection,
required final GrammarMatch match,
});
/// Checks one editor's text (ADR 0024): once the writer pauses for
/// [idleDelay] and as the editor loses the focus, never at each key. An edit
/// drops what was found at once, so an underline never points at the wrong
/// word, and checks anew after the next pause. Nothing is written into the
/// document; the overlay draws [spans], and a suggestion taken is an edit
/// like any other ([applyReplacement]).
///
/// A paragraph goes as it changed: one whose text is as it was keeps what
/// was found in it, so typing in one of ten paragraphs checks one. One
/// check runs at a time, and one asked for meanwhile runs once after it.
/// A language the instance doesn't check ends the checking for good.
class GrammarCheckController(
final EditorState _editorState,
final GrammarGateway _gateway, {
/// The language tag the text is in (`de`); none: recognised.
final String? language,
/// The editor's focus, to check as it is left.
final FocusNode? focusNode,
/// How long the writer pauses before a check.
final Duration idleDelay = const Duration(milliseconds: 1500),
}) extends ChangeNotifier {
this {
_changes = _editorState.transactionStream.listen(_onTransaction);
focusNode?.addListener(_onFocus);
_hadFocus = focusNode?.hasFocus ?? false;
}
late final StreamSubscription<EditorTransactionValue> _changes;
Timer? _pause;
/// Counts the edits: a check that sees it change while it waits for an
/// answer drops the answer, which is about text no longer there.
var _edits = 0;
var _hadFocus = false;
var _disposed = false;
var _stopped = false;
/// What was found in each paragraph's text, as it was checked: the same
/// text in the same language finds the same, wherever the paragraph now
/// is. Kept to the document's paragraphs after each check; failures are
/// never kept, so they are asked again.
final _found = <String, List<GrammarMatch>>{};
var _running = false;
var _again = false;
List<GrammarSpan> _spans = const [];
/// What was found, where it is.
List<GrammarSpan> get spans => _spans;
GrammarCheckStatus _status = GrammarCheckStatus.idle;
/// What the last check concluded; idle again after any edit.
GrammarCheckStatus get status => _status;
void _onTransaction(EditorTransactionValue value) {
final (time, transaction, _) = value;
// Moving the caret is a transaction too, and changes no text.
if (_disposed ||
time != TransactionTime.after ||
transaction.operations.isEmpty) {
return;
}
_edits++;
if (_spans.isNotEmpty || _status != GrammarCheckStatus.idle) {
_spans = const [];
_status = GrammarCheckStatus.idle;
notifyListeners();
}
_pause?.cancel();
_pause = Timer(idleDelay, () => unawaited(_run()));
}
void _onFocus() {
final has = focusNode?.hasFocus ?? false;
// Left before the pause was over: checked now.
if (_hadFocus && !has && !_disposed) {
_pause?.cancel();
unawaited(_run());
}
_hadFocus = has;
}
/// Checks the text as it is now, as the editor opens.
Future<void> checkNow() => _run();
Future<void> _run() async {
if (_disposed || _stopped) {
return;
}
if (_running) {
_again = true;
return;
}
_running = true;
try {
await _check();
while (_again && !_disposed && !_stopped) {
_again = false;
await _check();
}
} finally {
_running = false;
_again = false;
}
}
Future<void> _check() async {
final edits = _edits;
final segments = grammarSegmentsOf(_editorState.document);
if (segments.isNotEmpty && _status != GrammarCheckStatus.checking) {
_status = GrammarCheckStatus.checking;
notifyListeners();
}
final spans = <GrammarSpan>[];
// What this check knows, the next one's to keep: two paragraphs alike
// cost one check.
final known = <String, List<GrammarMatch>>{};
var failed = false;
for (final segment in segments) {
var matches = known[segment.text] ?? _found[segment.text];
if (matches == null) {
try {
matches = await _gateway.check(segment.text, language: language);
} on GrammarRefused catch (refused) {
if (refused.reason == GrammarRefusalReason.unknownLanguage) {
_stopped = true;
}
failed = true;
continue;
} on Object {
// Best effort: offline or away, this paragraph shows nothing
// this time.
failed = true;
continue;
}
if (_disposed || edits != _edits) {
return;
}
}
known[segment.text] = matches;
for (final match in matches) {
if (match.offset < 0 ||
match.end > segment.text.length ||
segment.touchesMask(match.offset, match.end)) {
continue;
}
spans.add(
GrammarSpan(
selection: Selection.single(
path: segment.path,
startOffset: match.offset,
endOffset: match.end,
),
match: match,
),
);
}
}
if (_disposed || edits != _edits) {
return;
}
_found
..clear()
..addAll(known);
_spans = spans;
_status = switch (segments.isEmpty) {
true => GrammarCheckStatus.idle,
false when spans.isNotEmpty => GrammarCheckStatus.issues,
false when failed => GrammarCheckStatus.failed,
false => GrammarCheckStatus.clean,
};
notifyListeners();
}
/// Puts [replacement] where [span] is, as one edit: the editor saves it
/// as any other, and it is checked again after the pause.
Future<void> applyReplacement(GrammarSpan span, String replacement) async {
final node = _editorState.getNodeAtPath(span.selection.start.path);
if (node == null) {
return;
}
final start = span.selection.startIndex;
final transaction = _editorState.transaction
..replaceText(
node,
start,
span.selection.endIndex - start,
replacement,
);
await _editorState.apply(transaction);
}
@override
void dispose() {
_disposed = true;
_pause?.cancel();
focusNode?.removeListener(_onFocus);
// Not awaited: a subscription's cancel can wait for its stream to end.
unawaited(_changes.cancel());
super.dispose();
}
}

View file

@ -0,0 +1,48 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_gateway.dart';
/// Spelling and grammar checked by the instance's LanguageTool (ADR 0024)
/// for the person signed in: the checks themselves, and which languages it
/// checks, asked once ([look]).
class GrammarChecks(
/// Where the checks go.
final GrammarGateway gateway,
final Future<List<String>?> Function() _ask,
) {
final _languages = ValueNotifier<Set<String>?>(null);
Future<void>? _asking;
/// The languages checked (`de`, `ja`), once known: none where the
/// instance checks none; null before it said.
ValueListenable<Set<String>?> get languages => _languages;
/// Asks the instance, unless asked already; asks again after a failure.
Future<void> look() => _asking ??= () async {
try {
_languages.value = (await _ask())?.toSet() ?? const {};
} on Object catch (error) {
log(
'Asking which languages are checked failed',
name: 'Grammar',
error: error,
);
_asking = null;
}
}();
/// Whether text in [language] (a tag, `de-AT`; none: recognised) can be
/// checked: one of [languages] by its language alone, or any where none
/// is said.
bool checks(String? language) => switch (_languages.value) {
null => false,
final known when known.isEmpty => false,
final known => switch (language?.trim()) {
null || '' => true,
final tag => known.contains(
tag.split(RegExp('[-_]')).first.toLowerCase(),
),
},
};
}

View file

@ -0,0 +1,45 @@
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_match.dart';
/// Checks spelling and grammar on the instance (ADR 0024), whose
/// LanguageTool only its server reaches.
abstract interface class GrammarGateway() {
/// What the instance finds in [text], checked in [language] (a language
/// tag, `de` or `en-GB`; none: the one it recognises). Throws
/// [GrammarRefused] where it checks nothing.
Future<List<GrammarMatch>> check(String text, {String? language});
}
/// [GrammarGateway] through the Serverpod client.
class ServerpodGrammarGateway(final protocol.Client _client)
implements GrammarGateway {
@override
Future<List<GrammarMatch>> check(String text, {String? language}) async {
try {
final check = await _client.grammar.check(
text: text,
language: language,
);
return [
for (final match in check.matches)
GrammarMatch(
offset: match.offset,
length: match.length,
message: match.message,
shortMessage: match.shortMessage,
replacements: match.replacements,
rule: match.rule,
category: match.category,
),
];
} on protocol.GrammarRefusedException catch (refused) {
throw GrammarRefused(switch (refused.reason) {
protocol.GrammarRefusal.unavailable => GrammarRefusalReason.unavailable,
protocol.GrammarRefusal.unknownLanguage =>
GrammarRefusalReason.unknownLanguage,
protocol.GrammarRefusal.tooLong => GrammarRefusalReason.tooLong,
protocol.GrammarRefusal.tooOften => GrammarRefusalReason.tooOften,
});
}
}
}

View file

@ -0,0 +1,263 @@
import 'dart:async';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/asset/widget/studyset_files.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_check_controller.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_overlay.dart';
import 'package:halkyon_learn_flutter/src/core/widget/content_languages.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The language an editor's text is checked in (ADR 0024): its studyset's
/// front or back language, as a card's sides are, or the one LanguageTool
/// recognises, as a note's.
enum GrammarLanguage() {
front,
back,
recognised,
}
/// [child], an editor of [editorState], with its text checked for spelling
/// and grammar as it is written (ADR 0024): where the device checks
/// writing (Settings › Writing), the instance checks the language, and the
/// studyset isn't private, or the person agreed for it. In a private
/// studyset not asked yet, a banner above the editor asks while it has the
/// focus, once for the studyset and its topics. What is found is drawn over
/// the editor ([GrammarOverlay]).
class const GrammarHost({
required final EditorState editorState,
required final FocusNode focusNode,
required final GrammarLanguage language,
required final Widget child,
super.key,
}) extends StatefulWidget {
@override
State<GrammarHost> createState() => _GrammarHostState();
}
class _GrammarHostState() extends State<GrammarHost> {
late final _person = PersonDependencies.of(context);
late final _settings = _person.settings.watch();
/// The studyset the editor's studyset is private by, by the studyset's
/// id; null for one that isn't private.
Future<String?>? _top;
String? _topOf;
GrammarCheckController? _controller;
/// The language [_controller] checks in, `''` for recognised.
String? _checkingIn;
@override
void initState() {
super.initState();
widget.focusNode.addListener(_onFocus);
unawaited(_person.grammar.look());
}
@override
void didUpdateWidget(GrammarHost oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.focusNode != widget.focusNode) {
oldWidget.focusNode.removeListener(_onFocus);
widget.focusNode.addListener(_onFocus);
}
}
@override
void dispose() {
widget.focusNode.removeListener(_onFocus);
_controller?.dispose();
super.dispose();
}
void _onFocus() => setState(() {});
/// The private top of the studyset files here belong to, worked out once
/// per studyset.
Future<String?> _privateTop(String? studysetId) {
if (_top == null || studysetId != _topOf) {
_topOf = studysetId;
_top = studysetId == null
? Future.value()
: _person.studysets.all().then(
(all) => privateTopOf(studysetId, all),
);
}
return _top!;
}
/// Checks in [language] (`''`: recognised), or not at all for null.
void _checkIn(String? language) {
if (language == _checkingIn) {
return;
}
_checkingIn = language;
// Once the overlay drawn over the editor has let it go.
if (_controller case final old?) {
WidgetsBinding.instance.addPostFrameCallback((_) => old.dispose());
}
_controller = language == null
? null
: GrammarCheckController(
widget.editorState,
_person.grammar.gateway,
language: language.isEmpty ? null : language,
focusNode: widget.focusNode,
);
if (_controller case final controller?) {
// What the editor opens with is checked too, once it has drawn.
WidgetsBinding.instance.addPostFrameCallback(
(_) => unawaited(controller.checkNow()),
);
}
}
Future<void> _answer(String top, {required bool check}) => _person.settings
.update((settings) => settings.withGrammarAnswer(top, check: check));
@override
Widget build(BuildContext context) {
final on = context.select<DeviceSettingsBloc, bool>(
(bloc) => bloc.state.checkGrammar,
);
final languages = ContentLanguages.of(context);
final tag = switch (widget.language) {
GrammarLanguage.front => languages.front,
GrammarLanguage.back => languages.back,
GrammarLanguage.recognised => null,
};
return ValueListenableBuilder<Set<String>?>(
valueListenable: _person.grammar.languages,
builder: (context, _, _) => FutureBuilder<String?>(
future: _privateTop(StudysetFiles.of(context)),
builder: (context, top) => StreamBuilder<PersonSettings>(
stream: _settings,
builder: (context, settings) {
final private = top.data;
final checkable =
on &&
top.connectionState == ConnectionState.done &&
_person.grammar.checks(tag);
final answer = private == null
? true
: settings.data?.grammarAnswerOf(private);
// Checked only once the settings tell, so a private studyset
// never goes before its answer is known.
final checks =
checkable &&
(private == null || settings.hasData) &&
answer == true;
_checkIn(checks ? tag ?? '' : null);
final asks =
checkable &&
private != null &&
settings.hasData &&
answer == null &&
widget.focusNode.hasFocus;
final controller = _controller;
final editor = controller == null
? widget.child
: Stack(
children: [
widget.child,
Positioned.fill(
child: GrammarOverlay(
editorState: widget.editorState,
controller: controller,
),
),
],
);
if (!asks) {
return editor;
}
final banner = _GrammarAsk(
onAnswer: (check) => unawaited(_answer(private, check: check)),
);
return LayoutBuilder(
builder: (context, constraints) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
banner,
if (constraints.hasBoundedHeight)
Expanded(child: editor)
else
editor,
],
),
);
},
),
),
);
}
}
/// Whether to check what is written in a private studyset (ADR 0024): where
/// it goes, and that it is asked once.
class const _GrammarAsk({
required final ValueChanged<bool> onAnswer,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 8, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
LucideIcons.spellCheck,
size: 20,
color: theme.colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.grammarPrivateAsk(AppScope.of(context).serverName),
),
),
],
),
OverflowBar(
alignment: MainAxisAlignment.end,
spacing: 8,
children: [
TextButton(
onPressed: () => onAnswer(false),
child: Text(l10n.grammarPrivateNotHere),
),
FilledButton.tonal(
onPressed: () => onAnswer(true),
child: Text(l10n.grammarPrivateCheckHere),
),
],
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,80 @@
import 'package:equatable/equatable.dart';
/// Something the instance's LanguageTool found in a text it checked (ADR
/// 0024): where, what is wrong, and what could stand there instead.
/// Shown, never kept.
final class const GrammarMatch({
/// Where it starts in the text checked, in UTF-16 code units, as the
/// editor's offsets count.
required final int offset,
/// How long it is, counted so.
required final int length,
/// What is wrong, in a sentence.
required final String message,
/// The same in a few words; may be empty.
final String shortMessage = '',
/// What could stand there instead, the likeliest first; none where the
/// rule only points something out.
final List<String> replacements = const [],
/// LanguageTool's rule (`MORFOLOGIK_RULE_EN_US`).
final String rule = '',
/// Its category (`TYPOS`, `GRAMMAR`, `STYLE`).
final String category = '',
}) with Equatable {
/// Where it ends, past its last code unit.
int get end => offset + length;
/// What a menu or list names it by: [shortMessage], else [message].
String get title => shortMessage.isNotEmpty ? shortMessage : message;
/// The same, [by] code units further on, as text before it changed
/// length.
GrammarMatch shifted(int by) => GrammarMatch(
offset: offset + by,
length: length,
message: message,
shortMessage: shortMessage,
replacements: replacements,
rule: rule,
category: category,
);
@override
List<Object?> get props => [
offset,
length,
message,
shortMessage,
replacements,
rule,
category,
];
}
/// Why the instance checked nothing.
enum GrammarRefusalReason() {
/// It checks nothing, or its LanguageTool didn't answer.
unavailable,
/// Its LanguageTool doesn't check the language asked for.
unknownLanguage,
/// The text was longer than one check takes.
tooLong,
/// More checks in the last minute than a person may ask for.
tooOften,
}
/// The instance checked nothing, for [reason].
final class const GrammarRefused(final GrammarRefusalReason reason)
implements Exception {
@override
String toString() => 'GrammarRefused(${reason.name})';
}

View file

@ -0,0 +1,298 @@
import 'dart:async';
import 'dart:math' as math;
// The editor's `Path` is a node's; the painter's is dart:ui's.
import 'package:appflowy_editor/appflowy_editor.dart' hide Path;
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_check_controller.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Red wavy lines under what [controller] found (ADR 0024), drawn over the
/// editor, whose document stays as it is; a tap on one offers its
/// suggestions in a menu, and the one chosen replaces the words. A muted
/// check in the corner says a check found nothing.
///
/// Fills the editor's box, over it in a `Stack`. Where the lines go is
/// worked out as the editor draws a selection
/// (`getRectsInSelection(…, shiftWithBaseOffset: true)`, then from the
/// node's box to this one), and anew whenever it could have moved: a
/// result or an edit, the editor's own scrolling, and this box changing
/// size as the text wraps anew (the keyboard, a pane resized).
class const GrammarOverlay({
required final EditorState editorState,
required final GrammarCheckController controller,
super.key,
}) extends StatefulWidget {
@override
State<GrammarOverlay> createState() => _GrammarOverlayState();
}
class _GrammarOverlayState() extends State<GrammarOverlay> {
final _stack = GlobalKey();
List<_Squiggle> _squiggles = const [];
/// The status drawn last: the check in the corner follows it, and only a
/// change of lines or status draws anew.
GrammarCheckStatus? _drawnStatus;
/// The editor's own scrolling, which moves the text under this box.
ScrollPosition? _scroll;
var _scheduled = false;
Size? _size;
@override
void initState() {
super.initState();
widget.controller.addListener(_schedule);
_schedule();
}
@override
void didUpdateWidget(GrammarOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
oldWidget.controller.removeListener(_schedule);
widget.controller.addListener(_schedule);
_schedule();
}
if (oldWidget.editorState != widget.editorState) {
_scroll?.removeListener(_schedule);
_scroll = null;
_schedule();
}
}
@override
void dispose() {
widget.controller.removeListener(_schedule);
_scroll?.removeListener(_schedule);
super.dispose();
}
/// Works the lines out after the next frame, once laid out (a scroll's
/// offset only reaches the render objects then), once per frame.
void _schedule() {
if (_scheduled) {
return;
}
_scheduled = true;
WidgetsBinding.instance
..addPostFrameCallback((_) {
_scheduled = false;
_place();
})
// A callback after a frame asks for none: a result arriving on a
// still screen would wait.
..ensureVisualUpdate();
}
/// Follows the editor's scrolling, which it makes as it first builds and
/// may replace.
void _followScroll() {
final scroll = widget.editorState.scrollableState?.position;
if (identical(scroll, _scroll)) {
return;
}
_scroll?.removeListener(_schedule);
_scroll = scroll?..addListener(_schedule);
}
void _place() {
if (!mounted) {
return;
}
_followScroll();
final box = _stack.currentContext?.findRenderObject();
if (box is! RenderBox || !box.hasSize) {
return;
}
final squiggles = <_Squiggle>[];
for (final span in widget.controller.spans) {
final node = widget.editorState.getNodeAtPath(span.selection.start.path);
final selectable = node?.selectable;
final nodeBox = node?.renderBox;
if (selectable == null || nodeBox == null) {
continue;
}
for (final rect in selectable.getRectsInSelection(
span.selection,
shiftWithBaseOffset: true,
)) {
squiggles.add(
_Squiggle(
rect:
box.globalToLocal(nodeBox.localToGlobal(rect.topLeft)) &
rect.size,
span: span,
),
);
}
}
final status = widget.controller.status;
if (status == _drawnStatus && _same(_squiggles, squiggles)) {
return;
}
setState(() {
_squiggles = squiggles;
_drawnStatus = status;
});
}
static bool _same(List<_Squiggle> a, List<_Squiggle> b) {
if (a.length != b.length) {
return false;
}
for (var i = 0; i < a.length; i++) {
// A new result is a new list of new spans.
if (a[i].rect != b[i].rect || !identical(a[i].span, b[i].span)) {
return false;
}
}
return true;
}
Future<void> _suggest(_Squiggle squiggle, Offset at) async {
final match = squiggle.span.match;
final overlay = Overlay.of(context).context.findRenderObject();
if (overlay is! RenderBox) {
return;
}
final theme = Theme.of(context);
final chosen = await showMenu<String>(
context: context,
position: RelativeRect.fromRect(
at & const Size(1, 1),
Offset.zero & overlay.size,
),
items: [
PopupMenuItem<String>(
enabled: false,
child: Text(
match.title,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
if (match.replacements.isEmpty)
PopupMenuItem<String>(
enabled: false,
child: Text(
AppLocalizations.of(context).grammarNoSuggestions,
style: theme.textTheme.bodyMedium,
),
)
else
for (final replacement in match.replacements)
PopupMenuItem<String>(value: replacement, child: Text(replacement)),
],
);
if (chosen != null) {
await widget.controller.applyReplacement(squiggle.span, chosen);
}
}
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) {
if (constraints.biggest != _size) {
// The text wrapped anew under the lines.
_size = constraints.biggest;
_schedule();
}
final theme = Theme.of(context);
return Stack(
key: _stack,
children: [
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: _SquigglePainter(
rects: [for (final squiggle in _squiggles) squiggle.rect],
color: theme.colorScheme.error,
),
),
),
),
// Taps only over the underlined words; elsewhere they reach the
// editor.
for (final squiggle in _squiggles)
Positioned.fromRect(
rect: squiggle.rect,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTapUp: (details) =>
unawaited(_suggest(squiggle, details.globalPosition)),
child: const SizedBox.expand(),
),
),
if (widget.controller.status == GrammarCheckStatus.clean)
Positioned(
right: 4,
bottom: 4,
child: IgnorePointer(
child: Semantics(
label: AppLocalizations.of(context).grammarNothingFound,
child: Icon(
LucideIcons.circleCheck,
size: 14,
color: theme.colorScheme.outline,
),
),
),
),
],
);
},
);
}
/// An underline: where it goes, and what it is under.
final class const _Squiggle({
required final Rect rect,
required final GrammarSpan span,
});
class _SquigglePainter({
required final List<Rect> rects,
required final Color color,
}) extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 1.5
..strokeCap = StrokeCap.round;
for (final rect in rects) {
const amplitude = 1.6;
const period = 6.0;
final y = rect.bottom - amplitude;
final wave = Path()..moveTo(rect.left, y);
for (var x = rect.left; x <= rect.right; x += 1) {
wave.lineTo(
x,
y + amplitude * math.sin((x - rect.left) / period * 2 * math.pi),
);
}
canvas.drawPath(wave, paint);
}
}
@override
bool shouldRepaint(_SquigglePainter oldDelegate) =>
oldDelegate.color != color || !_sameRects(oldDelegate.rects, rects);
static bool _sameRects(List<Rect> a, List<Rect> b) {
if (a.length != b.length) {
return false;
}
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) {
return false;
}
}
return true;
}
}

View file

@ -0,0 +1,62 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
/// A paragraph to check (ADR 0024): the text of the node at [path], so what
/// is found at an offset of [text] is at that offset of the node. The
/// editor's nodes are checked one at a time for that: a whole document
/// would need its offsets mapped back, and a match could span two nodes.
final class const GrammarSegment({
/// The node's path in the document.
required final Path path,
/// The node's text, what isn't prose ([masked]) as spaces of its length.
required final String text,
/// Where [text] holds spaces in place of a formula or code, from start to
/// past the end.
final List<(int, int)> masked = const [],
}) with Equatable {
/// Whether [start] to [end] touches what was masked: what is found there
/// is the masking's (the spaces), not the writer's.
bool touchesMask(int start, int end) =>
masked.any((range) => start < range.$2 && end > range.$1);
@override
List<Object?> get props => [path, text, masked];
}
/// The paragraphs of [document] to check, in its order: every node with
/// text that isn't blank, nested ones too. Formulas within lines (their
/// LaTeX) and code are masked: a checker reads them as broken prose.
List<GrammarSegment> grammarSegmentsOf(Document document) {
final segments = <GrammarSegment>[];
void walk(Node parent) {
for (final node in parent.children) {
if (node.delta case final delta?) {
final text = StringBuffer();
final masked = <(int, int)>[];
for (final operation in delta.whereType<TextInsert>()) {
final attributes = operation.attributes;
if (attributes?[inlineMathKey] == true ||
attributes?[AppFlowyRichTextKeys.code] == true) {
masked.add((text.length, text.length + operation.text.length));
text.write(' ' * operation.text.length);
} else {
text.write(operation.text);
}
}
final written = text.toString();
if (written.trim().isNotEmpty) {
segments.add(
GrammarSegment(path: node.path, text: written, masked: masked),
);
}
}
walk(node);
}
}
walk(document.root);
return segments;
}

View file

@ -15,8 +15,10 @@ import 'package:halkyon_learn_flutter/src/feature/assistant/widget/answerers.dar
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/asking.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_words.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/conversation_history.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/grammar_check_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -134,26 +136,72 @@ class _AssistantFieldState() extends State<AssistantField> {
bool? _nested;
var _nestedRead = false;
/// The front language of the topic's studyset, what Check checks in
/// (D54); null without, or until read ([_frontRead]).
String? _front;
var _frontRead = false;
late final _grammar = PersonDependencies.of(context).grammar;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_nestedRead && widget.topic.studysetId != null) {
if (!_nestedRead) {
_nestedRead = true;
unawaited(_readNested(PersonDependencies.of(context).studysets));
unawaited(_grammar.look());
if (widget.topic.studysetId == null) {
_frontRead = true;
} else {
unawaited(_readNested(PersonDependencies.of(context).studysets));
}
}
}
Future<void> _readNested(StudysetRepository studysets) async {
final id = widget.topic.studysetId;
final id = widget.topic.studysetId!;
final all = await studysets.all();
final nested =
all.where((studyset) => studyset.id == id).firstOrNull?.parentId !=
null;
final front = languagesOf(id, all).front;
if (mounted) {
setState(() => _nested = nested);
setState(() {
_nested = nested;
_front = front;
_frontRead = true;
});
}
}
/// Has what was written checked: by the instance's LanguageTool where it
/// checks the language (ADR 0024), its sheet offering [assistant] to say
/// why; else by [assistant], as D54 has it.
Future<void> _check(AssistantAnswerer? assistant) async {
if (_question.text.trim().isEmpty) {
return;
}
if (!_grammar.checks(_front)) {
if (assistant != null) {
await _send(assistant, task: AssistantTask.check);
}
return;
}
await showGrammarCheckSheet(
context,
text: _question,
language: _front,
askWith: switch (assistant) {
AssistantAnswerer(:final choice) => choice.name,
null => null,
},
onAsk: assistant == null
? null
: (checked) => unawaited(
_send(assistant, task: AssistantTask.check, asked: checked),
),
);
}
@override
void dispose() {
_question.dispose();
@ -182,18 +230,21 @@ class _AssistantFieldState() extends State<AssistantField> {
});
}
/// Asks [answerer] the question written, with the parts chosen; an
/// assistant also [task], checking what was written only once something
/// is (D54).
/// Asks [answerer] the question written ([asked] in its place), with
/// the parts chosen; an assistant also [task], checking what was written
/// only once something is (D54).
Future<void> _send(
Answerer answerer, {
AssistantTask task = AssistantTask.ask,
/// What is asked, in place of what the field holds.
String? asked,
}) async {
final chosen = [
for (final part in _offered(answerer))
if (_chosen.contains(part)) part,
];
final question = _question.text;
final question = asked ?? _question.text;
if (question.trim().isEmpty &&
(chosen.isEmpty || task == AssistantTask.check)) {
return;
@ -308,21 +359,28 @@ class _AssistantFieldState() extends State<AssistantField> {
),
),
ListenableBuilder(
listenable: _question,
listenable: Listenable.merge([_question, _grammar.languages]),
builder: (context, _) {
final written = _question.text.trim().isNotEmpty;
// Who says why, where the instance checks: the one asked, else
// the first assistant offered.
final assistant = switch (answerer) {
final AssistantAnswerer asked => asked,
_ => answerers.whereType<AssistantAnswerer>().firstOrNull,
};
final checks =
asksAssistant || (_frontRead && _grammar.checks(_front));
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// What was written checked for mistakes (D54).
if (asksAssistant)
// What was written checked for mistakes (D54), first by
// the instance's LanguageTool (ADR 0024).
if (checks)
IconButton(
tooltip: l10n.assistantCheck,
icon: const Icon(LucideIcons.spellCheck),
onPressed: written
? () => unawaited(
_send(answerer, task: AssistantTask.check),
)
? () => unawaited(_check(assistant))
: null,
),
IconButton.filled(

Some files were not shown because too many files have changed in this diff Show more