Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Language selector #44

Closed
wants to merge 9 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions example/lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,10 @@ class App extends StatefulWidget {
}

class _AppState extends State<App> {
/// Initialize LanguageTool
static final LanguageTool _languageTool = LanguageTool();

/// Initialize DebounceLangToolService
static final DebounceLangToolService _debouncedLangService =
DebounceLangToolService(
LangToolService(_languageTool),
const LangToolService(),
const Duration(milliseconds: 500),
);

Expand All @@ -30,15 +27,23 @@ class _AppState extends State<App> {
MainAxisAlignment.start,
MainAxisAlignment.end,
];
int currentAlignmentIndex = 0;

MainAxisAlignment currentAlignment = alignments.first;

@override
Widget build(BuildContext context) {
return Material(
child: Scaffold(
body: Column(
mainAxisAlignment: alignments[currentAlignmentIndex],
mainAxisAlignment: currentAlignment,
children: [
LanguageSelectDropdown(
languageFetchService: const CachingLangFetchService(
LangFetchService(),
),
onSelected: (language) =>
_controller.checkLanguage = language.longCode,
),
LanguageToolTextField(
style: const TextStyle(),
decoration: const InputDecoration(),
Expand All @@ -48,7 +53,8 @@ class _AppState extends State<App> {
DropdownMenu(
hintText: "Select alignment...",
onSelected: (value) => setState(() {
currentAlignmentIndex = value ?? 0;
currentAlignment =
value != null ? alignments[value] : currentAlignment;
}),
dropdownMenuEntries: const [
DropdownMenuEntry(value: 0, label: "Center alignment"),
Expand Down
43 changes: 38 additions & 5 deletions lib/core/controllers/colored_text_editing_controller.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import 'dart:math';
import 'dart:math' show min;

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
Expand Down Expand Up @@ -26,6 +26,31 @@ class ColoredTextEditingController extends TextEditingController {
/// Callback that will be executed after mistake clicked
ShowPopupCallback? showPopup;

/// Currently set spellcheck language.
String _checkLanguage;

/// Current picky mode.
bool _isPicky;

/// The currently set spellcheck language of this controller.
String get checkLanguage => _checkLanguage;

/// Sets the given [languageCode] as a new check language and re-checks the
/// current text of this controller.
set checkLanguage(String languageCode) {
_checkLanguage = languageCode;
_handleTextChange(value.text, force: true);
}

/// Whether additional spellcheck rules are enabled.
bool get isPicky => _isPicky;

/// Sets the picky mode and re-checks the current text of this controller.
set isPicky(bool value) {
_isPicky = value;
_handleTextChange(this.value.text, force: true);
}

@override
set value(TextEditingValue newValue) {
_handleTextChange(newValue.text);
Expand All @@ -36,7 +61,10 @@ class ColoredTextEditingController extends TextEditingController {
ColoredTextEditingController({
required this.languageCheckService,
this.highlightStyle = const HighlightStyle(),
});
String checkLanguage = 'auto',
bool isPicky = false,
}) : _checkLanguage = checkLanguage,
_isPicky = isPicky;

/// Generates TextSpan from Mistake list
@override
Expand Down Expand Up @@ -72,18 +100,23 @@ class ColoredTextEditingController extends TextEditingController {

/// Clear mistakes list when text mas modified and get a new list of mistakes
/// via API
Future<void> _handleTextChange(String newText) async {
Future<void> _handleTextChange(String newText, {bool force = false}) async {
///set value triggers each time, even when cursor changes its location
///so this check avoid cleaning Mistake list when text wasn't really changed
if (newText == text) return;
if (newText == text && !force) return;

_mistakes.clear();
for (final recognizer in _recognizers) {
recognizer.dispose();
}
_recognizers.clear();

final mistakes = await languageCheckService.findMistakes(newText);
final mistakes = await languageCheckService.findMistakes(
newText,
checkLanguage: checkLanguage,
isPicky: isPicky,
);

_mistakes = mistakes;
notifyListeners();
}
Expand Down
23 changes: 23 additions & 0 deletions lib/core/dataclasses/language/language.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/// A dataclass representing a language supported by the LanguageToolPlus API.
class Language {
/// A human-readable name of this spell check language in English.
final String name;

/// A short language code of this language.
final String code;

/// Creates a new [Language] with the provided [name] and [code].
const Language({required this.name, required this.code});

/// Reads a [Language] from the given [json].
factory Language.fromJson(Map<String, dynamic> json) => Language(
name: json['name'] as String,
code: json['code'] as String,
);

/// Creates a Map<String, dynamic> json from this [Language].
Map<String, dynamic> toJson() => {
'name': name,
'code': code,
};
}
33 changes: 33 additions & 0 deletions lib/core/dataclasses/language/supported_language.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import 'package:languagetool_textfield/core/dataclasses/language/language.dart';

/// A dataclass representing a supported language returned from the 'languages'
/// API endpoint.
class SupportedLanguage extends Language {
/// A long code of this language.
final String longCode;

/// Creates a new [SupportedLanguage] with the provided [name], [code], and
/// [longCode].
const SupportedLanguage({
required super.name,
required super.code,
required this.longCode,
});

/// Reads a [SupportedLanguage] from the given [json].
factory SupportedLanguage.fromJson(Map<String, dynamic> json) {
final detectedLanguage = Language.fromJson(json);

return SupportedLanguage(
name: detectedLanguage.name,
code: detectedLanguage.code,
longCode: json['longCode'] as String,
);
}

@override
Map<String, dynamic> toJson() => {
...super.toJson(),
'longCode': longCode,
};
}
72 changes: 72 additions & 0 deletions lib/core/dataclasses/mistake_match.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import 'package:languagetool_textfield/core/dataclasses/mistake_replacement.dart';

/// A dataclass representing a single matched mistake returned from the API.
class MistakeMatch {
/// A human-readable message describing mistake.
final String message;

/// A shorter message describing this mistake.
/// Can be empty.
final String shortMessage;

/// An offset of this mistake in the checked text.
final int offset;

/// A length of this mistake match.
final int length;

/// A complete sentence containing this mistake.
final String sentence;

/// A list of suggested replacements for this mistake.
final List<MistakeReplacement> replacements;

/// A type of the rule applied to this mistake.
final String issueType;

/// Creates a new [MistakeMatch] with the provided parameters.
const MistakeMatch({
required this.message,
required this.shortMessage,
required this.offset,
required this.length,
required this.sentence,
required this.replacements,
required this.issueType,
});

/// Reads a [MistakeMatch] from the given [json].
factory MistakeMatch.fromJson(Map<String, dynamic> json) {
final replacementsJson = json['replacements'] as List<dynamic>;
final ruleJson = json['rule'] as Map<String, dynamic>;
final replacements = replacementsJson
.cast<Map<String, dynamic>>()
.map(
MistakeReplacement.fromJson,
)
.toList(growable: false);

return MistakeMatch(
message: json['message'] as String,
shortMessage: json['shortMessage'] as String,
offset: json['offset'] as int,
length: json['length'] as int,
sentence: json['sentence'] as String,
replacements: replacements,
issueType: ruleJson['issueType'] as String,
);
}

/// Creates a Map<String, dynamic> json from this [MistakeMatch].
Map<String, dynamic> toJson() => {
'message': message,
'shortMessage': shortMessage,
'offset': offset,
'length': length,
'sentence': sentence,
'replacements': replacements.map((e) => e.toJson()).toList(
growable: false,
),
'issueType': issueType,
};
}
17 changes: 17 additions & 0 deletions lib/core/dataclasses/mistake_replacement.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/// A dataclass representing a single mistake replacement suggestion.
class MistakeReplacement {
/// A value of this [MistakeReplacement] suggestion.
final String value;

/// Creates a new [MistakeReplacement] with the provided [value].
const MistakeReplacement({required this.value});

/// Reads a [MistakeReplacement] from the given [json].
factory MistakeReplacement.fromJson(Map<String, dynamic> json) =>
MistakeReplacement(value: json['value'] as String);

/// Creates a Map<String, dynamic> json from this [MistakeReplacement].
Map<String, dynamic> toJson() => {
'value': value,
};
}
13 changes: 12 additions & 1 deletion lib/core/enums/mistake_type.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,16 @@ enum MistakeType {
style,

/// Any other mistake type
other,
other;

factory MistakeType.fromSting(String issueType) {
if (issueType == 'non-conformance') {
return MistakeType.nonConformance;
}

return values.firstWhere(
(e) => e.name == issueType,
orElse: () => MistakeType.other,
);
}
}
93 changes: 93 additions & 0 deletions lib/domain/api_request_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import 'dart:convert';
import 'dart:io';

import 'package:http/http.dart' as http;

/// A function that takes a json map and returns a value of type [T].
typedef JsonConverter<T> = T Function(dynamic json);

/// A base api request service.
/// Provides a [makeRequest] method to fetch a value of type [T] from the API.
///
/// The response body will be decoded from json and converted with the
/// [jsonConverter].
///
/// Http errors occurred during the request will be caught.
class ApiRequestService<T> {
/// An API link.
static const String apiLink = 'api.languagetoolplus.com';

/// A [JsonConverter] to read the type [T] from a fetched json map.
final JsonConverter<T> jsonConverter;

// todo temporary until the Result wrapper is merged
/// A value that will be returned instead of the result if an error has
/// occurred during the fetch.
final T orElse;

/// Creates a new [ApiRequestService] with the provided [jsonConverter].
const ApiRequestService(this.jsonConverter, this.orElse);

// todo change it to return the Result wrapper when merged
/// Sends the given [request] to the given [uri] and casts the result with
/// the [jsonConverter].
///
/// If an error has occurred during the request, the value of [orElse] will
/// be returned.
Future<T> makeRequest(Uri uri, http.BaseRequest request) async {
final client = http.Client();
Object? error;
http.Response? response;

try {
response = await http.Response.fromStream(await client.send(request));
} on http.ClientException catch (err) {
error = err;
}

if (response?.statusCode != HttpStatus.ok) {
error ??= http.ClientException(
response?.reasonPhrase ?? 'Could not request',
uri,
);
}

if (error != null || response == null || response.bodyBytes.isEmpty) {
return orElse;
}

final decoded = jsonDecode(utf8.decode(response.bodyBytes));

return jsonConverter(decoded);
}

/// Makes a GET request to the [uri] with the given [headers] and casts the
/// decoded result with the [jsonConverter].
///
/// Catches the http errors during the request. If an error has occurred,
/// returns the [orElse] value.
Future<T> get(Uri uri, {Map<String, String> headers = const {}}) {
final request = http.Request('GET', uri)..headers.addAll(headers);

return makeRequest(uri, request);
}

/// Makes a POST request to the [uri] with the given [headers] and [body] and
/// casts the decoded result with the [jsonConverter].
///
/// Catches the http errors during the request. If an error has occurred,
/// returns the [orElse] value.
Future<T> post(
Uri uri, {
Map<String, String> headers = const {},
Object? body,
}) {
final request = http.Request('POST', uri)..headers.addAll(headers);

if (body != null) {
request.bodyBytes = utf8.encode(jsonEncode(body));
}

return makeRequest(uri, request);
}
}
Loading