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:
parent
f766a880ac
commit
d2eaef255e
43 changed files with 2986 additions and 32 deletions
|
|
@ -587,6 +587,10 @@
|
||||||
"settingsStudy": "Lernen",
|
"settingsStudy": "Lernen",
|
||||||
"settingsStudyPomodoroTitle": "Pomodoro-Timer beim Lernen anzeigen",
|
"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.",
|
"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})",
|
"settingsVersion": "Version {version} (Build {build})",
|
||||||
"syncTitle": "Synchronisierung",
|
"syncTitle": "Synchronisierung",
|
||||||
"syncStateSyncing": "Wird synchronisiert…",
|
"syncStateSyncing": "Wird synchronisiert…",
|
||||||
|
|
@ -1880,12 +1884,27 @@
|
||||||
"assistantPrivateMark": "{name} darf dieses private Lernset bekommen",
|
"assistantPrivateMark": "{name} darf dieses private Lernset bekommen",
|
||||||
"studysetPrivacyAssistants": "Assistenten, an die es gehen darf",
|
"studysetPrivacyAssistants": "Assistenten, an die es gehen darf",
|
||||||
"studysetPrivacyTakeBack": "Zurücknehmen",
|
"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.",
|
"studyMissedExplain": "Nicht gewusst? {name} kann erklären, warum.",
|
||||||
"assistantExplain": "Erklären",
|
"assistantExplain": "Erklären",
|
||||||
"assistantExplainQuestion": "Warum ist das die Antwort?",
|
"assistantExplainQuestion": "Warum ist das die Antwort?",
|
||||||
"studyTypedWhy": "Warum?",
|
"studyTypedWhy": "Warum?",
|
||||||
"assistantWhyTyped": "Ich habe „{typed}“ getippt. Warum stimmt das nicht?",
|
"assistantWhyTyped": "Ich habe „{typed}“ getippt. Warum stimmt das nicht?",
|
||||||
"assistantCheck": "Auf Fehler prüfen",
|
"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}",
|
"proposalsTitle": "Karteikarten von {name}",
|
||||||
"proposalsWriting": "{name} schreibt Karteikarten …",
|
"proposalsWriting": "{name} schreibt Karteikarten …",
|
||||||
"proposalsNone": "{name} hat keine Karteikarten vorgeschlagen.",
|
"proposalsNone": "{name} hat keine Karteikarten vorgeschlagen.",
|
||||||
|
|
|
||||||
|
|
@ -666,6 +666,12 @@
|
||||||
"settingsStudy": "Study",
|
"settingsStudy": "Study",
|
||||||
"settingsStudyPomodoroTitle": "Show the Pomodoro timer while studying",
|
"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.",
|
"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": "Version {version} (build {build})",
|
||||||
"@settingsVersion": {"placeholders": {"version": {"type": "String"}, "build": {"type": "String"}}},
|
"@settingsVersion": {"placeholders": {"version": {"type": "String"}, "build": {"type": "String"}}},
|
||||||
"syncTitle": "Sync",
|
"syncTitle": "Sync",
|
||||||
|
|
@ -2217,6 +2223,11 @@
|
||||||
"@studysetPrivacyAssistants": {"description": "The privacy dialog's list of the assistants agreed to (D17)."},
|
"@studysetPrivacyAssistants": {"description": "The privacy dialog's list of the assistants agreed to (D17)."},
|
||||||
"studysetPrivacyTakeBack": "Take back",
|
"studysetPrivacyTakeBack": "Take back",
|
||||||
"@studysetPrivacyTakeBack": {"description": "Takes an agreement 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": "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"}}},
|
"@studyMissedExplain": {"description": "After a card was rated Bad (ADR 0021, D53); name is the assistant's.", "placeholders": {"name": {"type": "String"}}},
|
||||||
"assistantExplain": "Explain",
|
"assistantExplain": "Explain",
|
||||||
|
|
@ -2229,6 +2240,23 @@
|
||||||
"@assistantWhyTyped": {"description": "The question an assistant is asked about a reading typed wrong.", "placeholders": {"typed": {"type": "String"}}},
|
"@assistantWhyTyped": {"description": "The question an assistant is asked about a reading typed wrong.", "placeholders": {"typed": {"type": "String"}}},
|
||||||
"assistantCheck": "Check for mistakes",
|
"assistantCheck": "Check for mistakes",
|
||||||
"@assistantCheck": {"description": "Has an assistant check what was written in the field (D54)."},
|
"@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": "Flashcards by {name}",
|
||||||
"@proposalsTitle": {"description": "The sheet of cards an assistant proposes (ADR 0021, D55).", "placeholders": {"name": {"type": "String"}}},
|
"@proposalsTitle": {"description": "The sheet of cards an assistant proposes (ADR 0021, D55).", "placeholders": {"name": {"type": "String"}}},
|
||||||
"proposalsWriting": "{name} is writing flashcards…",
|
"proposalsWriting": "{name} is writing flashcards…",
|
||||||
|
|
|
||||||
|
|
@ -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.'**
|
/// **'Show the focus / break countdown inside the study session, so you can see the time left without opening the notification.'**
|
||||||
String get settingsStudyPomodoroSubtitle;
|
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.
|
/// No description provided for @settingsVersion.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -11102,6 +11126,24 @@ abstract class AppLocalizations {
|
||||||
/// **'Take back'**
|
/// **'Take back'**
|
||||||
String get studysetPrivacyTakeBack;
|
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.
|
/// After a card was rated Bad (ADR 0021, D53); name is the assistant's.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -11138,6 +11180,78 @@ abstract class AppLocalizations {
|
||||||
/// **'Check for mistakes'**
|
/// **'Check for mistakes'**
|
||||||
String get assistantCheck;
|
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).
|
/// The sheet of cards an assistant proposes (ADR 0021, D55).
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -2118,6 +2118,20 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get settingsStudyPomodoroSubtitle =>
|
String get settingsStudyPomodoroSubtitle =>
|
||||||
'Zeige den Fokus-/Pausen-Countdown in der Lernsitzung, damit du die verbleibende Zeit siehst, ohne die Benachrichtigung zu öffnen.';
|
'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
|
@override
|
||||||
String settingsVersion(String version, String build) {
|
String settingsVersion(String version, String build) {
|
||||||
return 'Version $version (Build $build)';
|
return 'Version $version (Build $build)';
|
||||||
|
|
@ -7005,6 +7019,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get studysetPrivacyTakeBack => 'Zurücknehmen';
|
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
|
@override
|
||||||
String studyMissedExplain(String name) {
|
String studyMissedExplain(String name) {
|
||||||
return 'Nicht gewusst? $name kann erklären, warum.';
|
return 'Nicht gewusst? $name kann erklären, warum.';
|
||||||
|
|
@ -7027,6 +7052,52 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get assistantCheck => 'Auf Fehler prüfen';
|
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
|
@override
|
||||||
String proposalsTitle(String name) {
|
String proposalsTitle(String name) {
|
||||||
return 'Karteikarten von $name';
|
return 'Karteikarten von $name';
|
||||||
|
|
|
||||||
|
|
@ -2098,6 +2098,20 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get settingsStudyPomodoroSubtitle =>
|
String get settingsStudyPomodoroSubtitle =>
|
||||||
'Show the focus / break countdown inside the study session, so you can see the time left without opening the notification.';
|
'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
|
@override
|
||||||
String settingsVersion(String version, String build) {
|
String settingsVersion(String version, String build) {
|
||||||
return 'Version $version (build $build)';
|
return 'Version $version (build $build)';
|
||||||
|
|
@ -6939,6 +6953,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get studysetPrivacyTakeBack => 'Take back';
|
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
|
@override
|
||||||
String studyMissedExplain(String name) {
|
String studyMissedExplain(String name) {
|
||||||
return 'Missed it? $name can explain why.';
|
return 'Missed it? $name can explain why.';
|
||||||
|
|
@ -6961,6 +6986,51 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get assistantCheck => 'Check for mistakes';
|
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
|
@override
|
||||||
String proposalsTitle(String name) {
|
String proposalsTitle(String name) {
|
||||||
return 'Flashcards by $name';
|
return 'Flashcards by $name';
|
||||||
|
|
|
||||||
|
|
@ -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/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/device_settings/model/device_settings.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/files/file_picker.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/content_keys.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
|
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy_ledger.dart';
|
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy_ledger.dart';
|
||||||
|
|
@ -637,6 +639,10 @@ Future<PersonDependencies> _openPersonData(
|
||||||
assets,
|
assets,
|
||||||
),
|
),
|
||||||
discussions: discussions,
|
discussions: discussions,
|
||||||
|
grammar: GrammarChecks(
|
||||||
|
ServerpodGrammarGateway(client),
|
||||||
|
() async => (await client.instance.info()).grammarLanguages,
|
||||||
|
),
|
||||||
onDispose: () async {
|
onDispose: () async {
|
||||||
try {
|
try {
|
||||||
// Deletions first: those still waiting are dropped, those under way
|
// Deletions first: those still waiting are dropped, those under way
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:halkyon_app/halkyon_app.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/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/assistant_lookups.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.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';
|
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
|
||||||
|
|
@ -120,6 +121,10 @@ class PersonDependencies({
|
||||||
|
|
||||||
/// Published studysets' discussions, on the instance.
|
/// Published studysets' discussions, on the instance.
|
||||||
required final DiscussionRepository discussions,
|
required final DiscussionRepository discussions,
|
||||||
|
|
||||||
|
/// Spelling and grammar checked by the instance's LanguageTool (ADR
|
||||||
|
/// 0024).
|
||||||
|
required final GrammarChecks grammar,
|
||||||
final Future<void> Function()? _onDispose,
|
final Future<void> Function()? _onDispose,
|
||||||
}) implements Disposable {
|
}) implements Disposable {
|
||||||
/// The dependencies of the person signed in above [context]
|
/// The dependencies of the person signed in above [context]
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ class DeviceSettingsBloc(
|
||||||
on<DeviceSettingsEventAskedWith>(
|
on<DeviceSettingsEventAskedWith>(
|
||||||
(event, emit) => _apply(emit, state.withAskWith(event.key)),
|
(event, emit) => _apply(emit, state.withAskWith(event.key)),
|
||||||
);
|
);
|
||||||
|
on<DeviceSettingsEventCheckGrammarChosen>(
|
||||||
|
(event, emit) => _apply(emit, state.withCheckGrammar(event.on)),
|
||||||
|
);
|
||||||
on<DeviceSettingsEventPdfSidebarDragged>(
|
on<DeviceSettingsEventPdfSidebarDragged>(
|
||||||
(event, emit) => _apply(emit, state.withPdfSidebarShare(event.share)),
|
(event, emit) => _apply(emit, state.withPdfSidebarShare(event.share)),
|
||||||
);
|
);
|
||||||
|
|
@ -123,6 +126,13 @@ final class const DeviceSettingsEventAskedWith(final String key)
|
||||||
List<Object?> get props => [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
|
/// The person shows the card browser of [studysetId] one card at a time
|
||||||
/// ([detailed]) or as a grid.
|
/// ([detailed]) or as a grid.
|
||||||
final class const DeviceSettingsEventBrowserViewChosen(
|
final class const DeviceSettingsEventBrowserViewChosen(
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
|
||||||
final String key => key,
|
final String key => key,
|
||||||
_ => null,
|
_ => null,
|
||||||
},
|
},
|
||||||
|
checkGrammar: json['checkGrammar'] != false,
|
||||||
);
|
);
|
||||||
} on FormatException catch (error) {
|
} on FormatException catch (error) {
|
||||||
log('The device settings are unreadable', name: 'Settings', error: 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,
|
'customSearchUrl': ?settings.customSearchUrl,
|
||||||
'pdfSidebarShare': ?settings.pdfSidebarShare,
|
'pdfSidebarShare': ?settings.pdfSidebarShare,
|
||||||
'askWith': ?settings.askWith,
|
'askWith': ?settings.askWith,
|
||||||
|
'checkGrammar': settings.checkGrammar,
|
||||||
}),
|
}),
|
||||||
flush: true,
|
flush: true,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ enum Appearance() {
|
||||||
/// app looks, the language it speaks, the style it cites sources in,
|
/// app looks, the language it speaks, the style it cites sources in,
|
||||||
/// whether studying shows the Pomodoro timer, which studysets' card
|
/// whether studying shows the Pomodoro timer, which studysets' card
|
||||||
/// browsers show one card at a time, whether and how the person searches
|
/// 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,
|
/// Unlike the person's settings, they don't follow anyone to other devices,
|
||||||
/// and they apply before anyone is signed in.
|
/// and they apply before anyone is signed in.
|
||||||
final class const DeviceSettings({
|
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);
|
/// granted, `own:<id>` for one with the person's own key (ADR 0021);
|
||||||
/// null before the first.
|
/// null before the first.
|
||||||
final String? askWith,
|
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 {
|
}) with Equatable {
|
||||||
/// These settings with [appearance] instead.
|
/// These settings with [appearance] instead.
|
||||||
DeviceSettings withAppearance(Appearance appearance) =>
|
DeviceSettings withAppearance(Appearance appearance) =>
|
||||||
|
|
@ -97,6 +103,9 @@ final class const DeviceSettings({
|
||||||
/// These settings with [key] having answered last ([askWith]).
|
/// These settings with [key] having answered last ([askWith]).
|
||||||
DeviceSettings withAskWith(String key) => _copy(askWith: key);
|
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({
|
DeviceSettings _copy({
|
||||||
Appearance? appearance,
|
Appearance? appearance,
|
||||||
Object? language = _keep,
|
Object? language = _keep,
|
||||||
|
|
@ -108,6 +117,7 @@ final class const DeviceSettings({
|
||||||
Object? customSearchUrl = _keep,
|
Object? customSearchUrl = _keep,
|
||||||
double? pdfSidebarShare,
|
double? pdfSidebarShare,
|
||||||
Object? askWith = _keep,
|
Object? askWith = _keep,
|
||||||
|
bool? checkGrammar,
|
||||||
}) => DeviceSettings(
|
}) => DeviceSettings(
|
||||||
appearance: appearance ?? this.appearance,
|
appearance: appearance ?? this.appearance,
|
||||||
language: identical(language, _keep) ? this.language : language as String?,
|
language: identical(language, _keep) ? this.language : language as String?,
|
||||||
|
|
@ -123,6 +133,7 @@ final class const DeviceSettings({
|
||||||
: customSearchUrl as String?,
|
: customSearchUrl as String?,
|
||||||
pdfSidebarShare: pdfSidebarShare ?? this.pdfSidebarShare,
|
pdfSidebarShare: pdfSidebarShare ?? this.pdfSidebarShare,
|
||||||
askWith: identical(askWith, _keep) ? this.askWith : askWith as String?,
|
askWith: identical(askWith, _keep) ? this.askWith : askWith as String?,
|
||||||
|
checkGrammar: checkGrammar ?? this.checkGrammar,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -137,6 +148,7 @@ final class const DeviceSettings({
|
||||||
customSearchUrl,
|
customSearchUrl,
|
||||||
pdfSidebarShare,
|
pdfSidebarShare,
|
||||||
askWith,
|
askWith,
|
||||||
|
checkGrammar,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/math_block.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_document.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/editor/ruby.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_host.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// Edits [initialContent] and reports each change as new [RichContent].
|
/// 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]).
|
/// left. On desktops, `/` opens the same blocks as Insert ([blocksOf]).
|
||||||
/// Copying and pasting keep the formats between the app's editors
|
/// Copying and pasting keep the formats between the app's editors
|
||||||
/// ([EditorClipboard]): with the keys on desktops, and on phones from the
|
/// ([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({
|
class const RichContentEditor({
|
||||||
/// The content the editor opens with; later changes to it are ignored.
|
/// The content the editor opens with; later changes to it are ignored.
|
||||||
required final RichContent initialContent,
|
required final RichContent initialContent,
|
||||||
|
|
@ -44,6 +47,9 @@ class const RichContentEditor({
|
||||||
|
|
||||||
/// The room around the text, within the editor's scrolling.
|
/// The room around the text, within the editor's scrolling.
|
||||||
final EdgeInsets padding = EdgeInsets.zero,
|
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,
|
super.key,
|
||||||
}) extends StatefulWidget {
|
}) extends StatefulWidget {
|
||||||
/// Whether an editor's popup (its `/` menu) has the focus for now: it
|
/// Whether an editor's popup (its `/` menu) has the focus for now: it
|
||||||
|
|
@ -187,6 +193,15 @@ class _RichContentEditorState() extends State<RichContentEditor> {
|
||||||
: appFlowyEditorAutoScrollEdgeOffset,
|
: appFlowyEditorAutoScrollEdgeOffset,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
final checked = switch (widget.grammar) {
|
||||||
|
final language? => GrammarHost(
|
||||||
|
editorState: _editorState,
|
||||||
|
focusNode: _focus,
|
||||||
|
language: language,
|
||||||
|
child: appFlowyEditor,
|
||||||
|
),
|
||||||
|
null => appFlowyEditor,
|
||||||
|
};
|
||||||
final editor = switch (defaultTargetPlatform) {
|
final editor = switch (defaultTargetPlatform) {
|
||||||
TargetPlatform.android || TargetPlatform.iOS => MobileFloatingToolbar(
|
TargetPlatform.android || TargetPlatform.iOS => MobileFloatingToolbar(
|
||||||
editorState: _editorState,
|
editorState: _editorState,
|
||||||
|
|
@ -217,9 +232,9 @@ class _RichContentEditorState() extends State<RichContentEditor> {
|
||||||
onShare: null,
|
onShare: null,
|
||||||
anchors: TextSelectionToolbarAnchors(primaryAnchor: anchor),
|
anchors: TextSelectionToolbarAnchors(primaryAnchor: anchor),
|
||||||
),
|
),
|
||||||
child: appFlowyEditor,
|
child: checked,
|
||||||
),
|
),
|
||||||
_ => appFlowyEditor,
|
_ => checked,
|
||||||
};
|
};
|
||||||
if (!widget.formatBar) {
|
if (!widget.formatBar) {
|
||||||
return editor;
|
return editor;
|
||||||
|
|
|
||||||
|
|
@ -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/asset/widget/studyset_files.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.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/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:halkyon_ui/halkyon_ui.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
|
@ -87,6 +88,7 @@ class _RichTextEditorState() extends State<_RichTextEditor> {
|
||||||
initialContent: widget.initial,
|
initialContent: widget.initial,
|
||||||
onChanged: (text) => _text = text,
|
onChanged: (text) => _text = text,
|
||||||
formatBar: true,
|
formatBar: true,
|
||||||
|
grammar: GrammarLanguage.recognised,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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})';
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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/asking.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_words.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/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/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/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:halkyon_ui/halkyon_ui.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
@ -134,26 +136,72 @@ class _AssistantFieldState() extends State<AssistantField> {
|
||||||
bool? _nested;
|
bool? _nested;
|
||||||
var _nestedRead = false;
|
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
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
if (!_nestedRead && widget.topic.studysetId != null) {
|
if (!_nestedRead) {
|
||||||
_nestedRead = true;
|
_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 {
|
Future<void> _readNested(StudysetRepository studysets) async {
|
||||||
final id = widget.topic.studysetId;
|
final id = widget.topic.studysetId!;
|
||||||
final all = await studysets.all();
|
final all = await studysets.all();
|
||||||
final nested =
|
final nested =
|
||||||
all.where((studyset) => studyset.id == id).firstOrNull?.parentId !=
|
all.where((studyset) => studyset.id == id).firstOrNull?.parentId !=
|
||||||
null;
|
null;
|
||||||
|
final front = languagesOf(id, all).front;
|
||||||
if (mounted) {
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_question.dispose();
|
_question.dispose();
|
||||||
|
|
@ -182,18 +230,21 @@ class _AssistantFieldState() extends State<AssistantField> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Asks [answerer] the question written, with the parts chosen; an
|
/// Asks [answerer] the question written ([asked] in its place), with
|
||||||
/// assistant also [task], checking what was written only once something
|
/// the parts chosen; an assistant also [task], checking what was written
|
||||||
/// is (D54).
|
/// only once something is (D54).
|
||||||
Future<void> _send(
|
Future<void> _send(
|
||||||
Answerer answerer, {
|
Answerer answerer, {
|
||||||
AssistantTask task = AssistantTask.ask,
|
AssistantTask task = AssistantTask.ask,
|
||||||
|
|
||||||
|
/// What is asked, in place of what the field holds.
|
||||||
|
String? asked,
|
||||||
}) async {
|
}) async {
|
||||||
final chosen = [
|
final chosen = [
|
||||||
for (final part in _offered(answerer))
|
for (final part in _offered(answerer))
|
||||||
if (_chosen.contains(part)) part,
|
if (_chosen.contains(part)) part,
|
||||||
];
|
];
|
||||||
final question = _question.text;
|
final question = asked ?? _question.text;
|
||||||
if (question.trim().isEmpty &&
|
if (question.trim().isEmpty &&
|
||||||
(chosen.isEmpty || task == AssistantTask.check)) {
|
(chosen.isEmpty || task == AssistantTask.check)) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -308,21 +359,28 @@ class _AssistantFieldState() extends State<AssistantField> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ListenableBuilder(
|
ListenableBuilder(
|
||||||
listenable: _question,
|
listenable: Listenable.merge([_question, _grammar.languages]),
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final written = _question.text.trim().isNotEmpty;
|
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(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// What was written checked for mistakes (D54).
|
// What was written checked for mistakes (D54), first by
|
||||||
if (asksAssistant)
|
// the instance's LanguageTool (ADR 0024).
|
||||||
|
if (checks)
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: l10n.assistantCheck,
|
tooltip: l10n.assistantCheck,
|
||||||
icon: const Icon(LucideIcons.spellCheck),
|
icon: const Icon(LucideIcons.spellCheck),
|
||||||
onPressed: written
|
onPressed: written
|
||||||
? () => unawaited(
|
? () => unawaited(_check(assistant))
|
||||||
_send(answerer, task: AssistantTask.check),
|
|
||||||
)
|
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
IconButton.filled(
|
IconButton.filled(
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue