Add Spellcheck to EditableText (Android) (#109334)
diff --git a/.ci.yaml b/.ci.yaml
index efde724..7872282 100644
--- a/.ci.yaml
+++ b/.ci.yaml
@@ -2102,6 +2102,17 @@
["devicelab", "android", "linux"]
task_name: routing_test
+ - name: Linux_android spell_check_test
+ bringup: true
+ recipe: devicelab/devicelab_drone
+ presubmit: false
+ timeout: 60
+ properties:
+ tags: >
+ ["devicelab", "android", "linux"]
+ task_name: spell_check_test
+ scheduler: luci
+
- name: Linux_android service_extensions_test
recipe: devicelab/devicelab_drone
presubmit: false
diff --git a/TESTOWNERS b/TESTOWNERS
index 9943552..14d2e5e 100644
--- a/TESTOWNERS
+++ b/TESTOWNERS
@@ -86,6 +86,7 @@
/dev/devicelab/bin/tasks/gradient_static_perf__e2e_summary.dart @flar @flutter/engine
/dev/devicelab/bin/tasks/animated_complex_opacity_perf__e2e_summary.dart @jonahwilliams @flutter/engine
/dev/devicelab/bin/tasks/openpay_benchmarks__scroll_perf.dart @iskakaushik @flutter/engine
+/dev/devicelab/bin/tasks/spell_check_test.dart @camsim99 @flutter/android
## Windows Android DeviceLab tests
/dev/devicelab/bin/tasks/basic_material_app_win__compile.dart @zanderso @flutter/tool
diff --git a/dev/devicelab/bin/tasks/spell_check_test.dart b/dev/devicelab/bin/tasks/spell_check_test.dart
new file mode 100644
index 0000000..fb2ab43
--- /dev/null
+++ b/dev/devicelab/bin/tasks/spell_check_test.dart
@@ -0,0 +1,12 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter_devicelab/framework/devices.dart';
+import 'package:flutter_devicelab/framework/framework.dart';
+import 'package:flutter_devicelab/tasks/integration_tests.dart';
+
+Future<void> main() async {
+ deviceOperatingSystem = DeviceOperatingSystem.android;
+ await task(createSpellCheckIntegrationTest());
+}
diff --git a/dev/devicelab/lib/tasks/integration_tests.dart b/dev/devicelab/lib/tasks/integration_tests.dart
index e17db08..87d114a 100644
--- a/dev/devicelab/lib/tasks/integration_tests.dart
+++ b/dev/devicelab/lib/tasks/integration_tests.dart
@@ -142,6 +142,13 @@
);
}
+TaskFunction createSpellCheckIntegrationTest() {
+ return IntegrationTest(
+ '${flutterDirectory.path}/dev/integration_tests/spell_check',
+ 'integration_test/integration_test.dart',
+ );
+}
+
class DriverTest {
DriverTest(
this.testDirectory,
diff --git a/dev/integration_tests/spell_check/README.md b/dev/integration_tests/spell_check/README.md
new file mode 100644
index 0000000..4fd36f0
--- /dev/null
+++ b/dev/integration_tests/spell_check/README.md
@@ -0,0 +1,3 @@
+# spell_check
+
+A Flutter project for testing spell check for [EditableText].
\ No newline at end of file
diff --git a/dev/integration_tests/spell_check/android/app/build.gradle b/dev/integration_tests/spell_check/android/app/build.gradle
new file mode 100644
index 0000000..4d04cfc
--- /dev/null
+++ b/dev/integration_tests/spell_check/android/app/build.gradle
@@ -0,0 +1,75 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+def localProperties = new Properties()
+def localPropertiesFile = rootProject.file('local.properties')
+if (localPropertiesFile.exists()) {
+ localPropertiesFile.withReader('UTF-8') { reader ->
+ localProperties.load(reader)
+ }
+}
+
+def flutterRoot = localProperties.getProperty('flutter.sdk')
+if (flutterRoot == null) {
+ throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
+}
+
+def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
+if (flutterVersionCode == null) {
+ flutterVersionCode = '1'
+}
+
+def flutterVersionName = localProperties.getProperty('flutter.versionName')
+if (flutterVersionName == null) {
+ flutterVersionName = '1.0'
+}
+
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
+
+android {
+ compileSdkVersion flutter.compileSdkVersion
+ ndkVersion flutter.ndkVersion
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = '1.8'
+ }
+
+ sourceSets {
+ main.java.srcDirs += 'src/main/kotlin'
+ }
+
+ defaultConfig {
+ // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
+ applicationId "com.example.spell_check"
+ // You can update the following values to match your application needs.
+ // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.
+ minSdkVersion flutter.minSdkVersion
+ targetSdkVersion flutter.targetSdkVersion
+ versionCode flutterVersionCode.toInteger()
+ versionName flutterVersionName
+ }
+
+ buildTypes {
+ release {
+ // TODO: Add your own signing config for the release build.
+ // Signing with the debug keys for now, so `flutter run --release` works.
+ signingConfig signingConfigs.debug
+ }
+ }
+}
+
+flutter {
+ source '../..'
+}
+
+dependencies {
+ implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
+}
diff --git a/dev/integration_tests/spell_check/android/app/src/debug/AndroidManifest.xml b/dev/integration_tests/spell_check/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..551c4d3
--- /dev/null
+++ b/dev/integration_tests/spell_check/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,12 @@
+<!-- Copyright 2014 The Flutter Authors. All rights reserved.
+Use of this source code is governed by a BSD-style license that can be
+found in the LICENSE file. -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.example.spell_check">
+ <!-- The INTERNET permission is required for development. Specifically,
+ the Flutter tool needs it to communicate with the running application
+ to allow setting breakpoints, to provide hot reload, etc.
+ -->
+ <uses-permission android:name="android.permission.INTERNET"/>
+</manifest>
diff --git a/dev/integration_tests/spell_check/android/app/src/main/AndroidManifest.xml b/dev/integration_tests/spell_check/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..57da833
--- /dev/null
+++ b/dev/integration_tests/spell_check/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,38 @@
+<!-- Copyright 2014 The Flutter Authors. All rights reserved.
+Use of this source code is governed by a BSD-style license that can be
+found in the LICENSE file. -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.example.spell_check">
+ <application
+ android:label="spell_check"
+ android:name="${applicationName}"
+ android:icon="@mipmap/ic_launcher">
+ <activity
+ android:name=".MainActivity"
+ android:exported="true"
+ android:launchMode="singleTop"
+ android:theme="@style/LaunchTheme"
+ android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
+ android:hardwareAccelerated="true"
+ android:windowSoftInputMode="adjustResize">
+ <!-- Specifies an Android theme to apply to this Activity as soon as
+ the Android process has started. This theme is visible to the user
+ while the Flutter UI initializes. After that, this theme continues
+ to determine the Window background behind the Flutter UI. -->
+ <meta-data
+ android:name="io.flutter.embedding.android.NormalTheme"
+ android:resource="@style/NormalTheme"
+ />
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN"/>
+ <category android:name="android.intent.category.LAUNCHER"/>
+ </intent-filter>
+ </activity>
+ <!-- Don't delete the meta-data below.
+ This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
+ <meta-data
+ android:name="flutterEmbedding"
+ android:value="2" />
+ </application>
+</manifest>
diff --git a/dev/integration_tests/spell_check/android/app/src/main/kotlin/com/example/sc_int_test/MainActivity.kt b/dev/integration_tests/spell_check/android/app/src/main/kotlin/com/example/sc_int_test/MainActivity.kt
new file mode 100644
index 0000000..d7090d4
--- /dev/null
+++ b/dev/integration_tests/spell_check/android/app/src/main/kotlin/com/example/sc_int_test/MainActivity.kt
@@ -0,0 +1,6 @@
+package com.example.spell_check
+
+import io.flutter.embedding.android.FlutterActivity
+
+class MainActivity: FlutterActivity() {
+}
diff --git a/dev/integration_tests/spell_check/android/app/src/main/res/drawable/launch_background.xml b/dev/integration_tests/spell_check/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..32d7798
--- /dev/null
+++ b/dev/integration_tests/spell_check/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,16 @@
+<!-- Copyright 2014 The Flutter Authors. All rights reserved.
+Use of this source code is governed by a BSD-style license that can be
+found in the LICENSE file. -->
+
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Modify this file to customize your launch splash screen -->
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+ <item android:drawable="@android:color/white" />
+
+ <!-- You can insert your own image assets here -->
+ <!-- <item>
+ <bitmap
+ android:gravity="center"
+ android:src="@mipmap/launch_image" />
+ </item> -->
+</layer-list>
diff --git a/dev/integration_tests/spell_check/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/dev/integration_tests/spell_check/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..db77bb4
--- /dev/null
+++ b/dev/integration_tests/spell_check/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Binary files differ
diff --git a/dev/integration_tests/spell_check/android/app/src/main/res/values/styles.xml b/dev/integration_tests/spell_check/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..c5ad344
--- /dev/null
+++ b/dev/integration_tests/spell_check/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,22 @@
+<!-- Copyright 2014 The Flutter Authors. All rights reserved.
+Use of this source code is governed by a BSD-style license that can be
+found in the LICENSE file. -->
+
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
+ <style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
+ <!-- Show a splash screen on the activity. Automatically removed when
+ Flutter draws its first frame -->
+ <item name="android:windowBackground">@drawable/launch_background</item>
+ </style>
+ <!-- Theme applied to the Android Window as soon as the process has started.
+ This theme determines the color of the Android Window while your
+ Flutter UI initializes, as well as behind your Flutter UI while its
+ running.
+
+ This Theme is only used starting with V2 of Flutter's Android embedding. -->
+ <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
+ <item name="android:windowBackground">?android:colorBackground</item>
+ </style>
+</resources>
diff --git a/dev/integration_tests/spell_check/android/app/src/profile/AndroidManifest.xml b/dev/integration_tests/spell_check/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000..551c4d3
--- /dev/null
+++ b/dev/integration_tests/spell_check/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,12 @@
+<!-- Copyright 2014 The Flutter Authors. All rights reserved.
+Use of this source code is governed by a BSD-style license that can be
+found in the LICENSE file. -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.example.spell_check">
+ <!-- The INTERNET permission is required for development. Specifically,
+ the Flutter tool needs it to communicate with the running application
+ to allow setting breakpoints, to provide hot reload, etc.
+ -->
+ <uses-permission android:name="android.permission.INTERNET"/>
+</manifest>
diff --git a/dev/integration_tests/spell_check/android/build.gradle b/dev/integration_tests/spell_check/android/build.gradle
new file mode 100644
index 0000000..2d60711
--- /dev/null
+++ b/dev/integration_tests/spell_check/android/build.gradle
@@ -0,0 +1,35 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+buildscript {
+ ext.kotlin_version = '1.6.10'
+ repositories {
+ google()
+ mavenCentral()
+ }
+
+ dependencies {
+ classpath 'com.android.tools.build:gradle:7.1.2'
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.buildDir = '../build'
+subprojects {
+ project.buildDir = "${rootProject.buildDir}/${project.name}"
+}
+subprojects {
+ project.evaluationDependsOn(':app')
+}
+
+task clean(type: Delete) {
+ delete rootProject.buildDir
+}
diff --git a/dev/integration_tests/spell_check/android/gradle.properties b/dev/integration_tests/spell_check/android/gradle.properties
new file mode 100644
index 0000000..94adc3a
--- /dev/null
+++ b/dev/integration_tests/spell_check/android/gradle.properties
@@ -0,0 +1,3 @@
+org.gradle.jvmargs=-Xmx1536M
+android.useAndroidX=true
+android.enableJetifier=true
diff --git a/dev/integration_tests/spell_check/android/gradle/wrapper/gradle-wrapper.properties b/dev/integration_tests/spell_check/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..cc5527d
--- /dev/null
+++ b/dev/integration_tests/spell_check/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Fri Jun 23 08:50:38 CEST 2017
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip
diff --git a/dev/integration_tests/spell_check/android/settings.gradle b/dev/integration_tests/spell_check/android/settings.gradle
new file mode 100644
index 0000000..d3b6a40
--- /dev/null
+++ b/dev/integration_tests/spell_check/android/settings.gradle
@@ -0,0 +1,15 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+include ':app'
+
+def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
+def properties = new Properties()
+
+assert localPropertiesFile.exists()
+localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
+
+def flutterSdkPath = properties.getProperty("flutter.sdk")
+assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
+apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
diff --git a/dev/integration_tests/spell_check/integration_test/integration_test.dart b/dev/integration_tests/spell_check/integration_test/integration_test.dart
new file mode 100644
index 0000000..2e57c2a
--- /dev/null
+++ b/dev/integration_tests/spell_check/integration_test/integration_test.dart
@@ -0,0 +1,188 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+import 'package:spell_check/main.dart';
+
+late DefaultSpellCheckService defaultSpellCheckService;
+late Locale locale;
+
+/// Copy from flutter/test/widgets/editable_text_utils.dart.
+RenderEditable findRenderEditable(WidgetTester tester, Type type) {
+ final RenderObject root = tester.renderObject(find.byType(type));
+ expect(root, isNotNull);
+
+ late RenderEditable renderEditable;
+ void recursiveFinder(RenderObject child) {
+ if (child is RenderEditable) {
+ renderEditable = child;
+ return;
+ }
+ child.visitChildren(recursiveFinder);
+ }
+ root.visitChildren(recursiveFinder);
+ expect(renderEditable, isNotNull);
+ return renderEditable;
+}
+
+Future<void> main() async {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+ setUp(() {
+ defaultSpellCheckService = DefaultSpellCheckService();
+ locale = const Locale('en', 'us');
+ });
+
+ test(
+ 'fetchSpellCheckSuggestions returns null with no misspelled words',
+ () async {
+ const String text = 'Hello, world!';
+
+ final List<SuggestionSpan>? spellCheckSuggestionSpans =
+ await defaultSpellCheckService.fetchSpellCheckSuggestions(locale, text);
+
+ expect(spellCheckSuggestionSpans!.length, equals(0));
+ expect(
+ defaultSpellCheckService.lastSavedResults!.spellCheckedText,
+ equals(text)
+ );
+ expect(
+ defaultSpellCheckService.lastSavedResults!.suggestionSpans,
+ equals(spellCheckSuggestionSpans)
+ );
+ });
+
+ test(
+ 'fetchSpellCheckSuggestions returns correct ranges with misspelled words',
+ () async {
+ const String text = 'Hlelo, world! Yuou are magnificente';
+ const List<TextRange> misspelledWordRanges = <TextRange>[
+ TextRange(start: 0, end: 5),
+ TextRange(start: 14, end: 18),
+ TextRange(start: 23, end: 35)
+ ];
+
+ final List<SuggestionSpan>? spellCheckSuggestionSpans =
+ await defaultSpellCheckService.fetchSpellCheckSuggestions(locale, text);
+
+ expect(spellCheckSuggestionSpans, isNotNull);
+ expect(
+ spellCheckSuggestionSpans!.length,
+ equals(misspelledWordRanges.length)
+ );
+
+ for (int i = 0; i < misspelledWordRanges.length; i += 1) {
+ expect(
+ spellCheckSuggestionSpans[i].range,
+ equals(misspelledWordRanges[i])
+ );
+ }
+
+ expect(
+ defaultSpellCheckService.lastSavedResults!.spellCheckedText,
+ equals(text)
+ );
+ expect(
+ defaultSpellCheckService.lastSavedResults!.suggestionSpans,
+ equals(spellCheckSuggestionSpans)
+ );
+ });
+
+ test(
+ 'fetchSpellCheckSuggestions does not correct results when Gboard not ignoring composing region',
+ () async {
+ const String text = 'Wwow, whaaett a beautiful day it is!';
+
+ final List<SuggestionSpan>? spellCheckSpansWithComposingRegion =
+ await defaultSpellCheckService.fetchSpellCheckSuggestions(locale, text);
+
+ expect(spellCheckSpansWithComposingRegion, isNotNull);
+ expect(spellCheckSpansWithComposingRegion!.length, equals(2));
+
+ final List<SuggestionSpan>? spellCheckSuggestionSpans =
+ await defaultSpellCheckService.fetchSpellCheckSuggestions(locale, text);
+
+ expect(
+ spellCheckSuggestionSpans,
+ equals(spellCheckSpansWithComposingRegion)
+ );
+ });
+
+ test(
+ 'fetchSpellCheckSuggestions merges results when Gboard ignoring composing region',
+ () async {
+ const String text = 'Wooahha it is an amazzinng dayyebf!';
+
+ final List<SuggestionSpan>? modifiedSpellCheckSuggestionSpans =
+ await defaultSpellCheckService.fetchSpellCheckSuggestions(locale, text);
+ final List<SuggestionSpan> expectedSpellCheckSuggestionSpans =
+ List<SuggestionSpan>.from(modifiedSpellCheckSuggestionSpans!);
+ expect(modifiedSpellCheckSuggestionSpans, isNotNull);
+ expect(modifiedSpellCheckSuggestionSpans.length, equals(3));
+
+ // Remove one span to simulate Gboard attempting to un-ignore the composing region, after tapping away from "Yuou".
+ modifiedSpellCheckSuggestionSpans.removeAt(1);
+
+ defaultSpellCheckService.lastSavedResults =
+ SpellCheckResults(text, modifiedSpellCheckSuggestionSpans);
+
+ final List<SuggestionSpan>? spellCheckSuggestionSpans =
+ await defaultSpellCheckService.fetchSpellCheckSuggestions(locale, text);
+
+ expect(spellCheckSuggestionSpans, isNotNull);
+ expect(
+ spellCheckSuggestionSpans,
+ equals(expectedSpellCheckSuggestionSpans)
+ );
+ });
+
+ testWidgets('EditableText spell checks when text is entered and spell check enabled', (WidgetTester tester) async {
+ const TextStyle style = TextStyle();
+ const TextStyle misspelledTextStyle = TextField.materialMisspelledTextStyle;
+
+ await tester.pumpWidget(const MyApp());
+
+ await tester.enterText(find.byType(EditableText), 'Hey wrororld! Hey!');
+ await tester.pumpAndSettle();
+
+ final RenderEditable renderEditable = findRenderEditable(tester, EditableText);
+ final TextSpan textSpanTree = renderEditable.text! as TextSpan;
+
+ const TextSpan expectedTextSpanTree = TextSpan(
+ style: style,
+ children: <TextSpan>[
+ TextSpan(style: style, text: 'Hey '),
+ TextSpan(style: misspelledTextStyle, text: 'wrororld'),
+ TextSpan(style: style, text: '! Hey!'),
+ ]);
+
+ expect(textSpanTree, equals(expectedTextSpanTree));
+ });
+
+ test(
+ 'fetchSpellCheckSuggestions returns null when there is a pending request',
+ () async {
+ final String text =
+ 'neaf niofenaifn iofn iefnaoeifn ifneoa finoiafn inf ionfieaon ienf ifn ieonfaiofneionf oieafn oifnaioe nioenfio nefaion oifan' *
+ 10;
+
+ defaultSpellCheckService.fetchSpellCheckSuggestions(locale, text);
+
+ final String modifiedText = text.substring(5);
+
+ final List<SuggestionSpan>? spellCheckSuggestionSpans =
+ await defaultSpellCheckService.fetchSpellCheckSuggestions(
+ locale, modifiedText);
+
+ expect(spellCheckSuggestionSpans, isNull);
+
+ // We expect it to be rare for the first request to complete before the
+ // second, so no text should be saved as of now.
+ expect(defaultSpellCheckService.lastSavedResults, null);
+ });
+}
diff --git a/dev/integration_tests/spell_check/lib/main.dart b/dev/integration_tests/spell_check/lib/main.dart
new file mode 100644
index 0000000..21f5ff7
--- /dev/null
+++ b/dev/integration_tests/spell_check/lib/main.dart
@@ -0,0 +1,57 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+
+void main() {
+ runApp(const MyApp());
+}
+
+class MyApp extends StatelessWidget {
+ const MyApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ title: 'Spellcheck Demo',
+ theme: ThemeData(
+ primarySwatch: Colors.blue,
+ ),
+ home: const MyHomePage(title: 'Spellcheck Demo'),
+ );
+ }
+}
+
+class MyHomePage extends StatefulWidget {
+ const MyHomePage({super.key, required this.title});
+
+ final String title;
+
+ @override
+ State<MyHomePage> createState() => _MyHomePageState();
+}
+
+class _MyHomePageState extends State<MyHomePage> {
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: Text(widget.title),
+ ),
+ body: Center(
+ child: EditableText(
+ controller: TextEditingController(),
+ backgroundCursorColor: Colors.grey,
+ focusNode: FocusNode(),
+ style: const TextStyle(),
+ cursorColor: Colors.red,
+ spellCheckConfiguration:
+ const SpellCheckConfiguration(
+ misspelledTextStyle: TextField.materialMisspelledTextStyle,
+ )
+ )
+ ),
+ );
+ }
+}
diff --git a/dev/integration_tests/spell_check/pubspec.yaml b/dev/integration_tests/spell_check/pubspec.yaml
new file mode 100644
index 0000000..e2640aa
--- /dev/null
+++ b/dev/integration_tests/spell_check/pubspec.yaml
@@ -0,0 +1,108 @@
+name: spell_check
+description: Integration test for spell check.
+
+# The following line prevents the package from being accidentally published to
+# pub.dev using `flutter pub publish`. This is preferred for private packages.
+publish_to: 'none' # Remove this line if you wish to publish to pub.dev
+
+# The following defines the version and build number for your application.
+# A version number is three numbers separated by dots, like 1.2.43
+# followed by an optional build number separated by a +.
+# Both the version and the builder number may be overridden in flutter
+# build by specifying --build-name and --build-number, respectively.
+# In Android, build-name is used as versionName while build-number used as versionCode.
+# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
+# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
+# Read more about iOS versioning at
+# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
+version: 1.0.0+1
+
+environment:
+ sdk: '>=2.18.0-149.0.dev <3.0.0'
+
+# Dependencies specify other packages that your package needs in order to work.
+# To automatically upgrade your package dependencies to the latest versions
+# consider running `flutter pub upgrade --major-versions`. Alternatively,
+# dependencies can be manually updated by changing the version numbers below to
+# the latest version available on pub.dev. To see which dependencies have newer
+# versions available, run `flutter pub outdated`.
+dependencies:
+ flutter:
+ sdk: flutter
+
+ # The following adds the Cupertino Icons font to your application.
+ # Use with the CupertinoIcons class for iOS style icons.
+ cupertino_icons: 1.0.5
+
+ characters: 1.2.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
+ collection: 1.16.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
+ material_color_utilities: 0.2.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
+ meta: 1.8.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
+ vector_math: 2.1.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
+
+dev_dependencies:
+ flutter_test:
+ sdk: flutter
+
+ # Used to run the integration tests in this app:
+ integration_test:
+ sdk: flutter
+
+# For information on the generic Dart part of this file, see the
+# following page: https://dart.dev/tools/pub/pubspec
+
+# The following section is specific to Flutter packages.
+
+ async: 2.9.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
+ boolean_selector: 2.1.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
+ clock: 1.1.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
+ fake_async: 1.3.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
+ matcher: 0.12.12 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
+ path: 1.8.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
+ source_span: 1.9.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
+ stack_trace: 1.10.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
+ stream_channel: 2.1.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
+ string_scanner: 1.1.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
+ term_glyph: 1.2.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
+ test_api: 0.4.12 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
+ vm_service: 9.3.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade"
+
+flutter:
+
+ # The following line ensures that the Material Icons font is
+ # included with your application, so that you can use the icons in
+ # the material Icons class.
+ uses-material-design: true
+
+ # To add assets to your application, add an assets section, like this:
+ # assets:
+ # - images/a_dot_burr.jpeg
+ # - images/a_dot_ham.jpeg
+
+ # An image asset can refer to one or more resolution-specific "variants", see
+ # https://flutter.dev/assets-and-images/#resolution-aware
+
+ # For details regarding adding assets from package dependencies, see
+ # https://flutter.dev/assets-and-images/#from-packages
+
+ # To add custom fonts to your application, add a fonts section here,
+ # in this "flutter" section. Each entry in this list should have a
+ # "family" key with the font family name, and a "fonts" key with a
+ # list giving the asset and other descriptors for the font. For
+ # example:
+ # fonts:
+ # - family: Schyler
+ # fonts:
+ # - asset: fonts/Schyler-Regular.ttf
+ # - asset: fonts/Schyler-Italic.ttf
+ # style: italic
+ # - family: Trajan Pro
+ # fonts:
+ # - asset: fonts/TrajanPro.ttf
+ # - asset: fonts/TrajanPro_Bold.ttf
+ # weight: 700
+ #
+ # For details regarding fonts from package dependencies,
+ # see https://flutter.dev/custom-fonts/#from-packages
+
+# PUBSPEC CHECKSUM: 53ec
diff --git a/dev/integration_tests/spell_check/test_driver/integration_test.dart b/dev/integration_tests/spell_check/test_driver/integration_test.dart
new file mode 100644
index 0000000..600bdc8
--- /dev/null
+++ b/dev/integration_tests/spell_check/test_driver/integration_test.dart
@@ -0,0 +1,7 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:integration_test/integration_test_driver.dart';
+
+Future<void> main() => integrationDriver();
diff --git a/packages/flutter/lib/services.dart b/packages/flutter/lib/services.dart
index 370df35..2fa143e 100644
--- a/packages/flutter/lib/services.dart
+++ b/packages/flutter/lib/services.dart
@@ -37,6 +37,7 @@
export 'src/services/raw_keyboard_web.dart';
export 'src/services/raw_keyboard_windows.dart';
export 'src/services/restoration.dart';
+export 'src/services/spell_check.dart';
export 'src/services/system_channels.dart';
export 'src/services/system_chrome.dart';
export 'src/services/system_navigator.dart';
diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart
index c155223..f239f14 100644
--- a/packages/flutter/lib/src/cupertino/text_field.dart
+++ b/packages/flutter/lib/src/cupertino/text_field.dart
@@ -273,6 +273,7 @@
this.restorationId,
this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true,
+ this.spellCheckConfiguration,
this.magnifierConfiguration,
}) : assert(textAlign != null),
assert(readOnly != null),
@@ -435,6 +436,7 @@
this.restorationId,
this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true,
+ this.spellCheckConfiguration,
this.magnifierConfiguration,
}) : assert(textAlign != null),
assert(readOnly != null),
@@ -800,6 +802,26 @@
// docs with images of what a magnifier is.
final TextMagnifierConfiguration? magnifierConfiguration;
+ /// {@macro flutter.widgets.EditableText.spellCheckConfiguration}
+ ///
+ /// If [SpellCheckConfiguration.misspelledTextStyle] is not specified in this
+ /// configuration, then [cupertinoMisspelledTextStyle] is used by default.
+ final SpellCheckConfiguration? spellCheckConfiguration;
+
+ /// The [TextStyle] used to indicate misspelled words in the Cupertino style.
+ ///
+ /// See also:
+ /// * [SpellCheckConfiguration.misspelledTextStyle], the style configured to
+ /// mark misspelled words with.
+ /// * [TextField.materialMisspelledTextStyle], the style configured
+ /// to mark misspelled words with in the Material style.
+ static const TextStyle cupertinoMisspelledTextStyle =
+ TextStyle(
+ decoration: TextDecoration.underline,
+ decorationColor: CupertinoColors.systemRed,
+ decorationStyle: TextDecorationStyle.dotted,
+ );
+
@override
State<CupertinoTextField> createState() => _CupertinoTextFieldState();
@@ -843,6 +865,7 @@
properties.add(DiagnosticsProperty<Clip>('clipBehavior', clipBehavior, defaultValue: Clip.hardEdge));
properties.add(DiagnosticsProperty<bool>('scribbleEnabled', scribbleEnabled, defaultValue: true));
properties.add(DiagnosticsProperty<bool>('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true));
+ properties.add(DiagnosticsProperty<SpellCheckConfiguration>('spellCheckConfiguration', spellCheckConfiguration, defaultValue: null));
}
static final TextMagnifierConfiguration _iosMagnifierConfiguration = TextMagnifierConfiguration(
@@ -1282,6 +1305,17 @@
context,
) ?? CupertinoTheme.of(context).primaryColor.withOpacity(0.2);
+ // Set configuration as disabled if not otherwise specified. If specified,
+ // ensure that configuration uses Cupertino text style for misspelled words
+ // unless a custom style is specified.
+ final SpellCheckConfiguration spellCheckConfiguration =
+ widget.spellCheckConfiguration != null &&
+ widget.spellCheckConfiguration != const SpellCheckConfiguration.disabled()
+ ? widget.spellCheckConfiguration!.copyWith(
+ misspelledTextStyle: widget.spellCheckConfiguration!.misspelledTextStyle
+ ?? CupertinoTextField.cupertinoMisspelledTextStyle)
+ : const SpellCheckConfiguration.disabled();
+
final Widget paddedEditable = Padding(
padding: widget.padding,
child: RepaintBoundary(
@@ -1346,6 +1380,7 @@
restorationId: 'editable',
scribbleEnabled: widget.scribbleEnabled,
enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
+ spellCheckConfiguration: spellCheckConfiguration,
),
),
),
diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart
index 1a36308..f4b660c 100644
--- a/packages/flutter/lib/src/material/text_field.dart
+++ b/packages/flutter/lib/src/material/text_field.dart
@@ -10,6 +10,7 @@
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
+import 'colors.dart';
import 'debug.dart';
import 'desktop_text_selection.dart';
import 'feedback.dart';
@@ -333,6 +334,7 @@
this.restorationId,
this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true,
+ this.spellCheckConfiguration,
this.magnifierConfiguration,
}) : assert(textAlign != null),
assert(readOnly != null),
@@ -800,6 +802,26 @@
/// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning}
final bool enableIMEPersonalizedLearning;
+ /// {@macro flutter.widgets.EditableText.spellCheckConfiguration}
+ ///
+ /// If [SpellCheckConfiguration.misspelledTextStyle] is not specified in this
+ /// configuration, then [materialMisspelledTextStyle] is used by default.
+ final SpellCheckConfiguration? spellCheckConfiguration;
+
+ /// The [TextStyle] used to indicate misspelled words in the Material style.
+ ///
+ /// See also:
+ /// * [SpellCheckConfiguration.misspelledTextStyle], the style configured to
+ /// mark misspelled words with.
+ /// * [CupertinoTextField.cupertinoMisspelledTextStyle], the style configured
+ /// to mark misspelled words with in the Cupertino style.
+ static const TextStyle materialMisspelledTextStyle =
+ TextStyle(
+ decoration: TextDecoration.underline,
+ decorationColor: Colors.red,
+ decorationStyle: TextDecorationStyle.wavy,
+ );
+
@override
State<TextField> createState() => _TextFieldState();
@@ -842,6 +864,7 @@
properties.add(DiagnosticsProperty<Clip>('clipBehavior', clipBehavior, defaultValue: Clip.hardEdge));
properties.add(DiagnosticsProperty<bool>('scribbleEnabled', scribbleEnabled, defaultValue: true));
properties.add(DiagnosticsProperty<bool>('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true));
+ properties.add(DiagnosticsProperty<SpellCheckConfiguration>('spellCheckConfiguration', spellCheckConfiguration, defaultValue: null));
}
}
@@ -1187,6 +1210,17 @@
),
];
+ // Set configuration as disabled if not otherwise specified. If specified,
+ // ensure that configuration uses Material text style for misspelled words
+ // unless a custom style is specified.
+ final SpellCheckConfiguration spellCheckConfiguration =
+ widget.spellCheckConfiguration != null &&
+ widget.spellCheckConfiguration != const SpellCheckConfiguration.disabled()
+ ? widget.spellCheckConfiguration!.copyWith(
+ misspelledTextStyle: widget.spellCheckConfiguration!.misspelledTextStyle
+ ?? TextField.materialMisspelledTextStyle)
+ : const SpellCheckConfiguration.disabled();
+
TextSelectionControls? textSelectionControls = widget.selectionControls;
final bool paintCursorAboveText;
final bool cursorOpacityAnimates;
@@ -1327,6 +1361,7 @@
restorationId: 'editable',
scribbleEnabled: widget.scribbleEnabled,
enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
+ spellCheckConfiguration: spellCheckConfiguration,
magnifierConfiguration: widget.magnifierConfiguration ?? TextMagnifier.adaptiveMagnifierConfiguration,
),
),
diff --git a/packages/flutter/lib/src/services/spell_check.dart b/packages/flutter/lib/src/services/spell_check.dart
new file mode 100644
index 0000000..595c719
--- /dev/null
+++ b/packages/flutter/lib/src/services/spell_check.dart
@@ -0,0 +1,216 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:ui';
+
+import 'package:flutter/foundation.dart';
+import 'system_channels.dart';
+
+/// A data structure representing a range of misspelled text and the suggested
+/// replacements for this range.
+///
+/// For example, one [SuggestionSpan] of the
+/// [List<SuggestionSpan>] suggestions of the [SpellCheckResults] corresponding
+/// to "Hello, wrold!" may be:
+/// ```dart
+/// SuggestionSpan(TextRange(7, 12), List<String>.from["word, world, old"])
+/// ```
+@immutable
+class SuggestionSpan {
+ /// Creates a span representing a misspelled range of text and the replacements
+ /// suggested by a spell checker.
+ ///
+ /// The [range] and replacement [suggestions] must all not
+ /// be null.
+ const SuggestionSpan(this.range, this.suggestions)
+ : assert(range != null),
+ assert(suggestions != null);
+
+ /// The misspelled range of text.
+ final TextRange range;
+
+ /// The alternate suggestions for the misspelled range of text.
+ final List<String> suggestions;
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) {
+ return true;
+ }
+
+ return other is SuggestionSpan &&
+ other.range.start == range.start &&
+ other.range.end == range.end &&
+ listEquals<String>(other.suggestions, suggestions);
+ }
+
+ @override
+ int get hashCode => Object.hash(range.start, range.end, Object.hashAll(suggestions));
+}
+
+/// A data structure grouping together the [SuggestionSpan]s and related text of
+/// results returned by a spell checker.
+@immutable
+class SpellCheckResults {
+ /// Creates results based off those received by spell checking some text input.
+ const SpellCheckResults(this.spellCheckedText, this.suggestionSpans)
+ : assert(spellCheckedText != null),
+ assert(suggestionSpans != null);
+
+ /// The text that the [suggestionSpans] correspond to.
+ final String spellCheckedText;
+
+ /// The spell check results of the [spellCheckedText].
+ ///
+ /// See also:
+ ///
+ /// * [SuggestionSpan], the ranges of misspelled text and corresponding
+ /// replacement suggestions.
+ final List<SuggestionSpan> suggestionSpans;
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) {
+ return true;
+ }
+
+ return other is SpellCheckResults &&
+ other.spellCheckedText == spellCheckedText &&
+ listEquals<SuggestionSpan>(other.suggestionSpans, suggestionSpans);
+ }
+
+ @override
+ int get hashCode => Object.hash(spellCheckedText, Object.hashAll(suggestionSpans));
+}
+
+/// Determines how spell check results are received for text input.
+abstract class SpellCheckService {
+ /// Facilitates a spell check request.
+ ///
+ /// Returns a [Future] that resolves with a [List] of [SuggestionSpan]s for
+ /// all misspelled words in the given [String] for the given [Locale].
+ Future<List<SuggestionSpan>?> fetchSpellCheckSuggestions(
+ Locale locale, String text
+ );
+}
+
+/// The service used by default to fetch spell check results for text input.
+///
+/// Any widget may use this service to spell check text by calling
+/// `fetchSpellCheckSuggestions(locale, text)` with an instance of this class.
+/// This is currently only supported by Android.
+///
+/// See also:
+///
+/// * [SpellCheckService], the service that this implements that may be
+/// overriden for use by [EditableText].
+/// * [EditableText], which may use this service to fetch results.
+class DefaultSpellCheckService implements SpellCheckService {
+ /// Creates service to spell check text input by default via communcication
+ /// over the spell check [MethodChannel].
+ DefaultSpellCheckService() {
+ spellCheckChannel = SystemChannels.spellCheck;
+ }
+
+ /// The last received results from the shell side.
+ SpellCheckResults? lastSavedResults;
+
+ /// The channel used to communicate with the shell side to complete spell
+ /// check requests.
+ late MethodChannel spellCheckChannel;
+
+ /// Merges two lists of spell check [SuggestionSpan]s.
+ ///
+ /// Used in cases where the text has not changed, but the spell check results
+ /// received from the shell side have. This case is caused by IMEs (GBoard,
+ /// for instance) that ignore the composing region when spell checking text.
+ ///
+ /// Assumes that the lists provided as parameters are sorted by range start
+ /// and that both list of [SuggestionSpan]s apply to the same text.
+ static List<SuggestionSpan> mergeResults(
+ List<SuggestionSpan> oldResults, List<SuggestionSpan> newResults) {
+ final List<SuggestionSpan> mergedResults = <SuggestionSpan>[];
+
+ SuggestionSpan oldSpan;
+ SuggestionSpan newSpan;
+ int oldSpanPointer = 0;
+ int newSpanPointer = 0;
+
+ while (oldSpanPointer < oldResults.length &&
+ newSpanPointer < newResults.length) {
+ oldSpan = oldResults[oldSpanPointer];
+ newSpan = newResults[newSpanPointer];
+
+ if (oldSpan.range.start == newSpan.range.start) {
+ mergedResults.add(oldSpan);
+ oldSpanPointer++;
+ newSpanPointer++;
+ } else {
+ if (oldSpan.range.start < newSpan.range.start) {
+ mergedResults.add(oldSpan);
+ oldSpanPointer++;
+ } else {
+ mergedResults.add(newSpan);
+ newSpanPointer++;
+ }
+ }
+ }
+
+ mergedResults.addAll(oldResults.sublist(oldSpanPointer));
+ mergedResults.addAll(newResults.sublist(newSpanPointer));
+
+ return mergedResults;
+ }
+
+ @override
+ Future<List<SuggestionSpan>?> fetchSpellCheckSuggestions(
+ Locale locale, String text) async {
+ assert(locale != null);
+ assert(text != null);
+
+ final List<dynamic> rawResults;
+ final String languageTag = locale.toLanguageTag();
+
+ try {
+ rawResults = await spellCheckChannel.invokeMethod(
+ 'SpellCheck.initiateSpellCheck',
+ <String>[languageTag, text],
+ ) as List<dynamic>;
+ } catch (e) {
+ // Spell check request canceled due to pending request.
+ return null;
+ }
+
+ List<SuggestionSpan> suggestionSpans = <SuggestionSpan>[];
+
+ for (final dynamic result in rawResults) {
+ final Map<String, dynamic> resultMap =
+ Map<String,dynamic>.from(result as Map<dynamic, dynamic>);
+ suggestionSpans.add(
+ SuggestionSpan(
+ TextRange(
+ start: resultMap['startIndex'] as int,
+ end: resultMap['endIndex'] as int),
+ (resultMap['suggestions'] as List<dynamic>).cast<String>(),
+ )
+ );
+ }
+
+ if (lastSavedResults != null) {
+ // Merge current and previous spell check results if between requests,
+ // the text has not changed but the spell check results have.
+ final bool textHasNotChanged = lastSavedResults!.spellCheckedText == text;
+ final bool spansHaveChanged =
+ listEquals(lastSavedResults!.suggestionSpans, suggestionSpans);
+
+ if (textHasNotChanged && spansHaveChanged) {
+ suggestionSpans = mergeResults(lastSavedResults!.suggestionSpans, suggestionSpans);
+ }
+
+ lastSavedResults = SpellCheckResults(text, suggestionSpans);
+ }
+
+ return suggestionSpans;
+ }
+}
diff --git a/packages/flutter/lib/src/services/system_channels.dart b/packages/flutter/lib/src/services/system_channels.dart
index 5d38359..2d18c0a 100644
--- a/packages/flutter/lib/src/services/system_channels.dart
+++ b/packages/flutter/lib/src/services/system_channels.dart
@@ -222,6 +222,28 @@
JSONMethodCodec(),
);
+ /// A [MethodChannel] for handling spell check for text input.
+ ///
+ /// This channel exposes the spell check framework for supported platforms.
+ /// Currently supported on Android only.
+ ///
+ /// Spell check requests are intiated by `SpellCheck.initiateSpellCheck`.
+ /// These requests may either be completed or canceled. If the request is
+ /// completed, the shell side will respond with the results of the request.
+ /// Otherwise, the shell side will respond with null.
+ ///
+ /// The following outgoing methods are defined for this channel (invoked by
+ /// [OptionalMethodChannel.invokeMethod]):
+ ///
+ /// * `SpellCheck.initiateSpellCheck`: Sends request for specified text to be
+ /// spell checked and returns the result, either a [List<SuggestionSpan>]
+ /// representing the spell check results of the text or null if the request
+ /// was cancelled. The arguments are the [String] to be spell checked
+ /// and the [Locale] for the text to be spell checked with.
+ static const MethodChannel spellCheck = OptionalMethodChannel(
+ 'flutter/spellcheck',
+ );
+
/// A JSON [BasicMessageChannel] for keyboard events.
///
/// Each incoming message received on this channel (registered using
diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart
index df06590..de40b05 100644
--- a/packages/flutter/lib/src/widgets/editable_text.dart
+++ b/packages/flutter/lib/src/widgets/editable_text.dart
@@ -34,6 +34,7 @@
import 'scroll_physics.dart';
import 'scrollable.dart';
import 'shortcuts.dart';
+import 'spell_check.dart';
import 'tap_region.dart';
import 'text.dart';
import 'text_editing_intents.dart';
@@ -171,9 +172,12 @@
// If the composing range is out of range for the current text, ignore it to
// preserve the tree integrity, otherwise in release mode a RangeError will
// be thrown and this EditableText will be built with a broken subtree.
- if (!value.isComposingRangeValid || !withComposing) {
+ final bool composingRegionOutOfRange = !value.isComposingRangeValid || !withComposing;
+
+ if (composingRegionOutOfRange) {
return TextSpan(style: style, text: text);
}
+
final TextStyle composingStyle = style?.merge(const TextStyle(decoration: TextDecoration.underline))
?? const TextStyle(decoration: TextDecoration.underline);
return TextSpan(
@@ -643,6 +647,7 @@
this.scrollBehavior,
this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true,
+ this.spellCheckConfiguration,
this.magnifierConfiguration = TextMagnifierConfiguration.disabled,
}) : assert(controller != null),
assert(focusNode != null),
@@ -706,6 +711,12 @@
))),
assert(clipBehavior != null),
assert(enableIMEPersonalizedLearning != null),
+ assert(
+ spellCheckConfiguration == null ||
+ spellCheckConfiguration == const SpellCheckConfiguration.disabled() ||
+ spellCheckConfiguration.misspelledTextStyle != null,
+ 'spellCheckConfiguration must specify a misspelledTextStyle if spell check behavior is desired',
+ ),
_strutStyle = strutStyle,
keyboardType = keyboardType ?? _inferKeyboardType(autofillHints: autofillHints, maxLines: maxLines),
inputFormatters = maxLines == 1
@@ -1555,6 +1566,20 @@
/// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning}
final bool enableIMEPersonalizedLearning;
+ /// {@template flutter.widgets.EditableText.spellCheckConfiguration}
+ /// Configuration that details how spell check should be performed.
+ ///
+ /// Specifies the [SpellCheckService] used to spell check text input and the
+ /// [TextStyle] used to style text with misspelled words.
+ ///
+ /// If the [SpellCheckService] is left null, spell check is disabled by
+ /// default unless the [DefaultSpellCheckService] is supported, in which case
+ /// it is used. It is currently supported only on Android.
+ ///
+ /// If this configuration is left null, then spell check is disabled by default.
+ /// {@endtemplate}
+ final SpellCheckConfiguration? spellCheckConfiguration;
+
/// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.intro}
///
/// {@macro flutter.widgets.magnifier.intro}
@@ -1738,6 +1763,7 @@
properties.add(DiagnosticsProperty<bool>('scribbleEnabled', scribbleEnabled, defaultValue: true));
properties.add(DiagnosticsProperty<bool>('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true));
properties.add(DiagnosticsProperty<bool>('enableInteractiveSelection', enableInteractiveSelection, defaultValue: true));
+ properties.add(DiagnosticsProperty<SpellCheckConfiguration>('spellCheckConfiguration', spellCheckConfiguration, defaultValue: null));
}
}
@@ -1774,6 +1800,31 @@
AutofillClient get _effectiveAutofillClient => widget.autofillClient ?? this;
+ late SpellCheckConfiguration _spellCheckConfiguration;
+
+ /// Configuration that determines how spell check will be performed.
+ ///
+ /// If possible, this configuration will contain a default for the
+ /// [SpellCheckService] if it is not otherwise specified.
+ ///
+ /// See also:
+ /// * [DefaultSpellCheckService], the spell check service used by default.
+ @visibleForTesting
+ SpellCheckConfiguration get spellCheckConfiguration => _spellCheckConfiguration;
+
+ /// Whether or not spell check is enabled.
+ ///
+ /// Spell check is enabled when a [SpellCheckConfiguration] has been specified
+ /// for the widget.
+ bool get spellCheckEnabled => _spellCheckConfiguration.spellCheckEnabled;
+
+ /// The most up-to-date spell check results for text input.
+ ///
+ /// These results will be updated via calls to spell check through a
+ /// [SpellCheckService] and used by this widget to build the [TextSpan] tree
+ /// for text input and menus for replacement suggestions of misspelled words.
+ SpellCheckResults? _spellCheckResults;
+
/// Whether to create an input connection with the platform for text editing
/// or not.
///
@@ -1960,6 +2011,28 @@
}
}
+ /// Infers the [SpellCheckConfiguration] used to perform spell check.
+ ///
+ /// If spell check is enabled, this will try to infer a value for
+ /// the [SpellCheckService] if left unspecified.
+ static SpellCheckConfiguration _inferSpellCheckConfiguration(SpellCheckConfiguration? configuration) {
+ if (configuration == null || configuration == const SpellCheckConfiguration.disabled()) {
+ return const SpellCheckConfiguration.disabled();
+ }
+
+ SpellCheckService? spellCheckService = configuration.spellCheckService;
+
+ assert(
+ spellCheckService != null
+ || WidgetsBinding.instance.platformDispatcher.nativeSpellCheckServiceDefined,
+ 'spellCheckService must be specified for this platform because no default service available',
+ );
+
+ spellCheckService = spellCheckService ?? DefaultSpellCheckService();
+
+ return configuration.copyWith(spellCheckService: spellCheckService);
+ }
+
// State lifecycle:
@override
@@ -1970,6 +2043,7 @@
widget.focusNode.addListener(_handleFocusChanged);
_scrollController.addListener(_updateSelectionOverlayForScroll);
_cursorVisibilityNotifier.value = widget.showCursor;
+ _spellCheckConfiguration = _inferSpellCheckConfiguration(widget.spellCheckConfiguration);
}
// Whether `TickerMode.of(context)` is true and animations (like blinking the
@@ -2817,6 +2891,37 @@
_lastBottomViewInset = WidgetsBinding.instance.window.viewInsets.bottom;
}
+ Future<void> _performSpellCheck(final String text) async {
+ try {
+ final Locale? localeForSpellChecking = widget.locale ?? Localizations.maybeLocaleOf(context);
+
+ assert(
+ localeForSpellChecking != null,
+ 'Locale must be specified in widget or Localization widget must be in scope',
+ );
+
+ final List<SuggestionSpan>? spellCheckResults = await
+ _spellCheckConfiguration
+ .spellCheckService!
+ .fetchSpellCheckSuggestions(localeForSpellChecking!, text);
+
+ if (spellCheckResults == null) {
+ // The request to fetch spell check suggestions was canceled due to ongoing request.
+ return;
+ }
+
+ _spellCheckResults = SpellCheckResults(text, spellCheckResults);
+ renderEditable.text = buildTextSpan();
+ } catch (exception, stack) {
+ FlutterError.reportError(FlutterErrorDetails(
+ exception: exception,
+ stack: stack,
+ library: 'widgets',
+ context: ErrorDescription('while performing spell check'),
+ ));
+ }
+ }
+
@pragma('vm:notify-debugger-on-exception')
void _formatAndSetValue(TextEditingValue value, SelectionChangedCause? cause, {bool userInteraction = false}) {
// Only apply input formatters if the text has changed (including uncommitted
@@ -2837,6 +2942,10 @@
value,
(TextEditingValue newValue, TextInputFormatter formatter) => formatter.formatEditUpdate(_value, newValue),
) ?? value;
+
+ if (spellCheckEnabled && value.text.isNotEmpty && _value.text != value.text) {
+ _performSpellCheck(value.text);
+ }
} catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetails(
exception: exception,
@@ -3732,12 +3841,30 @@
],
);
}
+ final bool spellCheckResultsReceived = spellCheckEnabled && _spellCheckResults != null;
+ final bool withComposing = !widget.readOnly && _hasFocus;
+ if (spellCheckResultsReceived) {
+ // If the composing range is out of range for the current text, ignore it to
+ // preserve the tree integrity, otherwise in release mode a RangeError will
+ // be thrown and this EditableText will be built with a broken subtree.
+ assert(!_value.composing.isValid || !withComposing || _value.isComposingRangeValid);
+
+ final bool composingRegionOutOfRange = !_value.isComposingRangeValid || !withComposing;
+
+ return buildTextSpanWithSpellCheckSuggestions(
+ _value,
+ composingRegionOutOfRange,
+ widget.style,
+ _spellCheckConfiguration.misspelledTextStyle!,
+ _spellCheckResults!,
+ );
+ }
// Read only mode should not paint text composing.
return widget.controller.buildTextSpan(
context: context,
style: widget.style,
- withComposing: !widget.readOnly && _hasFocus,
+ withComposing: withComposing,
);
}
}
diff --git a/packages/flutter/lib/src/widgets/spell_check.dart b/packages/flutter/lib/src/widgets/spell_check.dart
new file mode 100644
index 0000000..6ebefb6
--- /dev/null
+++ b/packages/flutter/lib/src/widgets/spell_check.dart
@@ -0,0 +1,330 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/painting.dart';
+import 'package:flutter/services.dart'
+ show SpellCheckResults, SpellCheckService, SuggestionSpan, TextEditingValue;
+
+/// Controls how spell check is performed for text input.
+///
+/// This configuration determines the [SpellCheckService] used to fetch the
+/// [List<SuggestionSpan>] spell check results and the [TextStyle] used to
+/// mark misspelled words within text input.
+@immutable
+class SpellCheckConfiguration {
+ /// Creates a configuration that specifies the service and suggestions handler
+ /// for spell check.
+ const SpellCheckConfiguration({
+ this.spellCheckService,
+ this.misspelledTextStyle,
+ }) : _spellCheckEnabled = true;
+
+ /// Creates a configuration that disables spell check.
+ const SpellCheckConfiguration.disabled()
+ : _spellCheckEnabled = false,
+ spellCheckService = null,
+ misspelledTextStyle = null;
+
+ /// The service used to fetch spell check results for text input.
+ final SpellCheckService? spellCheckService;
+
+ /// Style used to indicate misspelled words.
+ ///
+ /// This is nullable to allow style-specific wrappers of [EditableText]
+ /// to infer this, but this must be specified if this configuration is
+ /// provided directly to [EditableText] or its construction will fail with an
+ /// assertion error.
+ final TextStyle? misspelledTextStyle;
+
+ final bool _spellCheckEnabled;
+
+ /// Whether or not the configuration should enable or disable spell check.
+ bool get spellCheckEnabled => _spellCheckEnabled;
+
+ /// Returns a copy of the current [SpellCheckConfiguration] instance with
+ /// specified overrides.
+ SpellCheckConfiguration copyWith({
+ SpellCheckService? spellCheckService,
+ TextStyle? misspelledTextStyle}) {
+ if (!_spellCheckEnabled) {
+ // A new configuration should be constructed to enable spell check.
+ return const SpellCheckConfiguration.disabled();
+ }
+
+ return SpellCheckConfiguration(
+ spellCheckService: spellCheckService ?? this.spellCheckService,
+ misspelledTextStyle: misspelledTextStyle ?? this.misspelledTextStyle,
+ );
+ }
+
+ @override
+ String toString() {
+ return '''
+ spell check enabled : $_spellCheckEnabled
+ spell check service : $spellCheckService
+ misspelled text style : $misspelledTextStyle
+'''
+ .trim();
+ }
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) {
+ return true;
+ }
+
+ return other is SpellCheckConfiguration
+ && other.spellCheckService == spellCheckService
+ && other.misspelledTextStyle == misspelledTextStyle
+ && other._spellCheckEnabled == _spellCheckEnabled;
+ }
+
+ @override
+ int get hashCode => Object.hash(spellCheckService, misspelledTextStyle, _spellCheckEnabled);
+}
+
+// Methods for displaying spell check results:
+
+/// Adjusts spell check results to correspond to [newText] if the only results
+/// that the handler has access to are the [results] corresponding to
+/// [resultsText].
+///
+/// Used in the case where the request for the spell check results of the
+/// [newText] is lagging in order to avoid display of incorrect results.
+List<SuggestionSpan> _correctSpellCheckResults(
+ String newText, String resultsText, List<SuggestionSpan> results) {
+ final List<SuggestionSpan> correctedSpellCheckResults = <SuggestionSpan>[];
+
+ int spanPointer = 0;
+ int offset = 0;
+ int foundIndex;
+ int spanLength;
+ SuggestionSpan currentSpan;
+ SuggestionSpan adjustedSpan;
+ String currentSpanText;
+ String newSpanText = '';
+ bool currentSpanValid = false;
+ RegExp regex;
+
+ // Assumes that the order of spans has not been jumbled for optimization
+ // purposes, and will only search since the previously found span.
+ int searchStart = 0;
+
+ while (spanPointer < results.length) {
+ // Try finding SuggestionSpan from old results (currentSpan) in new text.
+ currentSpan = results[spanPointer];
+ currentSpanText =
+ resultsText.substring(currentSpan.range.start, currentSpan.range.end);
+
+ try {
+ // currentSpan was found and can be applied to new text.
+ newSpanText = newText.substring(
+ currentSpan.range.start + offset, currentSpan.range.end + offset);
+ currentSpanValid = true;
+ } catch (e) {
+ // currentSpan is invalid and needs to be searched for in newText.
+ currentSpanValid = false;
+ }
+
+ if (currentSpanValid && newSpanText == currentSpanText) {
+ // currentSpan was found at the same index in new text and old text
+ // (resultsText), so apply it to new text by adding it to the list of
+ // corrected results.
+ searchStart = currentSpan.range.end + offset;
+ adjustedSpan = SuggestionSpan(
+ TextRange(
+ start: currentSpan.range.start + offset, end: searchStart),
+ currentSpan.suggestions
+ );
+ correctedSpellCheckResults.add(adjustedSpan);
+ } else {
+ // Search for currentSpan in new text and if found, apply it to new text
+ // by adding it to the list of corrected results.
+ regex = RegExp('\\b$currentSpanText\\b');
+ foundIndex = newText.substring(searchStart).indexOf(regex);
+
+ if (foundIndex >= 0) {
+ foundIndex += searchStart;
+ spanLength = currentSpan.range.end - currentSpan.range.start;
+ searchStart = foundIndex + spanLength;
+ adjustedSpan = SuggestionSpan(
+ TextRange(start: foundIndex, end: searchStart),
+ currentSpan.suggestions
+ );
+ offset = foundIndex - currentSpan.range.start;
+
+ correctedSpellCheckResults.add(adjustedSpan);
+ }
+ }
+ spanPointer++;
+ }
+
+ return correctedSpellCheckResults;
+}
+
+/// Builds the [TextSpan] tree given the current state of the text input and
+/// spell check results.
+///
+/// The [value] is the current [TextEditingValue] requested to be rendered
+/// by a text input widget. The [composingWithinCurrentTextRange] value
+/// represents whether or not there is a valid composing region in the
+/// [value]. The [style] is the [TextStyle] to render the [value]'s text with,
+/// and the [misspelledTextStyle] is the [TextStyle] to render misspelled
+/// words within the [value]'s text with. The [spellCheckResults] are the
+/// results of spell checking the [value]'s text.
+TextSpan buildTextSpanWithSpellCheckSuggestions(
+ TextEditingValue value,
+ bool composingWithinCurrentTextRange,
+ TextStyle? style,
+ TextStyle misspelledTextStyle,
+ SpellCheckResults spellCheckResults) {
+ List<SuggestionSpan> spellCheckResultsSpans =
+ spellCheckResults.suggestionSpans;
+ final String spellCheckResultsText = spellCheckResults.spellCheckedText;
+
+ if (spellCheckResultsText != value.text) {
+ spellCheckResultsSpans = _correctSpellCheckResults(
+ value.text, spellCheckResultsText, spellCheckResultsSpans);
+ }
+
+ return TextSpan(
+ style: style,
+ children: _buildSubtreesWithMisspelledWordsIndicated(
+ spellCheckResultsSpans,
+ value,
+ style,
+ misspelledTextStyle,
+ composingWithinCurrentTextRange
+ )
+ );
+}
+
+/// Builds [TextSpan] subtree for text with misspelled words.
+List<TextSpan> _buildSubtreesWithMisspelledWordsIndicated(
+ List<SuggestionSpan>? spellCheckSuggestions,
+ TextEditingValue value,
+ TextStyle? style,
+ TextStyle misspelledStyle,
+ bool composingWithinCurrentTextRange) {
+ final List<TextSpan> tsTreeChildren = <TextSpan>[];
+
+ int textPointer = 0;
+ int currSpanPointer = 0;
+ int endIndex;
+ SuggestionSpan currSpan;
+ final String text = value.text;
+ final TextRange composingRegion = value.composing;
+ final TextStyle composingTextStyle =
+ style?.merge(const TextStyle(decoration: TextDecoration.underline)) ??
+ const TextStyle(decoration: TextDecoration.underline);
+ final TextStyle misspelledJointStyle =
+ style?.merge(misspelledStyle) ?? misspelledStyle;
+ bool textPointerWithinComposingRegion = false;
+ bool currSpanIsComposingRegion = false;
+
+ // Add text interwoven with any misspelled words to the tree.
+ if (spellCheckSuggestions != null) {
+ while (textPointer < text.length &&
+ currSpanPointer < spellCheckSuggestions.length) {
+ currSpan = spellCheckSuggestions[currSpanPointer];
+
+ if (currSpan.range.start > textPointer) {
+ endIndex = currSpan.range.start < text.length
+ ? currSpan.range.start
+ : text.length;
+ textPointerWithinComposingRegion =
+ composingRegion.start >= textPointer &&
+ composingRegion.end <= endIndex &&
+ !composingWithinCurrentTextRange;
+
+ if (textPointerWithinComposingRegion) {
+ _addComposingRegionTextSpans(tsTreeChildren, text, textPointer,
+ composingRegion, style, composingTextStyle);
+ tsTreeChildren.add(
+ TextSpan(
+ style: style,
+ text: text.substring(composingRegion.end, endIndex)
+ )
+ );
+ } else {
+ tsTreeChildren.add(
+ TextSpan(
+ style: style,
+ text: text.substring(textPointer, endIndex)
+ )
+ );
+ }
+
+ textPointer = endIndex;
+ } else {
+ endIndex =
+ currSpan.range.end < text.length ? currSpan.range.end : text.length;
+ currSpanIsComposingRegion = textPointer >= composingRegion.start &&
+ endIndex <= composingRegion.end &&
+ !composingWithinCurrentTextRange;
+ tsTreeChildren.add(
+ TextSpan(
+ style: currSpanIsComposingRegion
+ ? composingTextStyle
+ : misspelledJointStyle,
+ text: text.substring(currSpan.range.start, endIndex)
+ )
+ );
+
+ textPointer = endIndex;
+ currSpanPointer++;
+ }
+ }
+ }
+
+ // Add any remaining text to the tree if applicable.
+ if (textPointer < text.length) {
+ if (textPointer < composingRegion.start &&
+ !composingWithinCurrentTextRange) {
+ _addComposingRegionTextSpans(tsTreeChildren, text, textPointer,
+ composingRegion, style, composingTextStyle);
+
+ if (composingRegion.end != text.length) {
+ tsTreeChildren.add(
+ TextSpan(
+ style: style,
+ text: text.substring(composingRegion.end, text.length)
+ )
+ );
+ }
+ } else {
+ tsTreeChildren.add(
+ TextSpan(
+ style: style, text: text.substring(textPointer, text.length)
+ )
+ );
+ }
+ }
+
+ return tsTreeChildren;
+}
+
+/// Helper method to create [TextSpan] tree children for specified range of
+/// text up to and including the composing region.
+void _addComposingRegionTextSpans(
+ List<TextSpan> treeChildren,
+ String text,
+ int start,
+ TextRange composingRegion,
+ TextStyle? style,
+ TextStyle composingTextStyle) {
+ treeChildren.add(
+ TextSpan(
+ style: style,
+ text: text.substring(start, composingRegion.start)
+ )
+ );
+ treeChildren.add(
+ TextSpan(
+ style: composingTextStyle,
+ text: text.substring(composingRegion.start, composingRegion.end)
+ )
+ );
+}
diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart
index af52953..5120fb6 100644
--- a/packages/flutter/lib/widgets.dart
+++ b/packages/flutter/lib/widgets.dart
@@ -128,6 +128,7 @@
export 'src/widgets/sliver_prototype_extent_list.dart';
export 'src/widgets/slotted_render_object_widget.dart';
export 'src/widgets/spacer.dart';
+export 'src/widgets/spell_check.dart';
export 'src/widgets/status_transitions.dart';
export 'src/widgets/table.dart';
export 'src/widgets/tap_region.dart';
diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart
index 584be02..c7921fd 100644
--- a/packages/flutter/test/widgets/editable_text_test.dart
+++ b/packages/flutter/test/widgets/editable_text_test.dart
@@ -12643,6 +12643,164 @@
});
});
+ group('Spell check', () {
+ testWidgets(
+ 'Spell check configured properly when spell check disabled by default',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ home: EditableText(
+ controller: TextEditingController(text: 'A'),
+ focusNode: FocusNode(),
+ style: const TextStyle(),
+ cursorColor: Colors.blue,
+ backgroundCursorColor: Colors.grey,
+ cursorOpacityAnimates: true,
+ autofillHints: null,
+ ),
+ ),
+ );
+
+ final EditableTextState state =
+ tester.state<EditableTextState>(find.byType(EditableText));
+ expect(state.spellCheckEnabled, isFalse);
+ });
+
+ testWidgets(
+ 'Spell check configured properly when spell check disabled manually',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ home: EditableText(
+ controller: TextEditingController(text: 'A'),
+ focusNode: FocusNode(),
+ style: const TextStyle(),
+ cursorColor: Colors.blue,
+ backgroundCursorColor: Colors.grey,
+ cursorOpacityAnimates: true,
+ autofillHints: null,
+ spellCheckConfiguration: const SpellCheckConfiguration.disabled(),
+ ),
+ ),
+ );
+
+ final EditableTextState state =
+ tester.state<EditableTextState>(find.byType(EditableText));
+ expect(state.spellCheckEnabled, isFalse);
+ });
+
+ testWidgets(
+ 'Error thrown when spell check configuration defined without specifying misspelled text style',
+ (WidgetTester tester) async {
+ expect(
+ () {
+ EditableText(
+ controller: TextEditingController(text: 'A'),
+ focusNode: FocusNode(),
+ style: const TextStyle(),
+ cursorColor: Colors.blue,
+ backgroundCursorColor: Colors.grey,
+ cursorOpacityAnimates: true,
+ autofillHints: null,
+ spellCheckConfiguration: const SpellCheckConfiguration(),
+ );
+ },
+ throwsAssertionError,
+ );
+ });
+
+ testWidgets(
+ 'Spell check configured properly when spell check enabled without specified spell check service and native spell check service defined',
+ (WidgetTester tester) async {
+ tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue =
+ true;
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: EditableText(
+ controller: TextEditingController(text: 'A'),
+ focusNode: FocusNode(),
+ style: const TextStyle(),
+ cursorColor: Colors.blue,
+ backgroundCursorColor: Colors.grey,
+ cursorOpacityAnimates: true,
+ autofillHints: null,
+ spellCheckConfiguration:
+ const SpellCheckConfiguration(
+ misspelledTextStyle: TextField.materialMisspelledTextStyle,
+ ),
+ ),
+ ),
+ );
+
+ final EditableTextState state =
+ tester.state<EditableTextState>(find.byType(EditableText));
+ expect(state.spellCheckEnabled, isTrue);
+ expect(
+ state.spellCheckConfiguration.spellCheckService.runtimeType,
+ equals(DefaultSpellCheckService),
+ );
+ tester.binding.platformDispatcher.clearNativeSpellCheckServiceDefined();
+ });
+
+ testWidgets(
+ 'Spell check configured properly with specified spell check service',
+ (WidgetTester tester) async {
+ final FakeSpellCheckService fakeSpellCheckService = FakeSpellCheckService();
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: EditableText(
+ controller: TextEditingController(text: 'A'),
+ focusNode: FocusNode(),
+ style: const TextStyle(),
+ cursorColor: Colors.blue,
+ backgroundCursorColor: Colors.grey,
+ cursorOpacityAnimates: true,
+ autofillHints: null,
+ spellCheckConfiguration:
+ SpellCheckConfiguration(
+ spellCheckService: fakeSpellCheckService,
+ misspelledTextStyle: TextField.materialMisspelledTextStyle,
+ ),
+ ),
+ ),
+ );
+
+ final EditableTextState state =
+ tester.state<EditableTextState>(find.byType(EditableText));
+ expect(
+ state.spellCheckConfiguration.spellCheckService.runtimeType,
+ equals(FakeSpellCheckService),
+ );
+ });
+
+ testWidgets(
+ 'Error thrown when spell check enabled but no default spell check service available',
+ (WidgetTester tester) async {
+ tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue =
+ false;
+
+ await tester.pumpWidget(
+ EditableText(
+ controller: TextEditingController(text: 'A'),
+ focusNode: FocusNode(),
+ style: const TextStyle(),
+ cursorColor: Colors.blue,
+ backgroundCursorColor: Colors.grey,
+ cursorOpacityAnimates: true,
+ autofillHints: null,
+ spellCheckConfiguration:
+ const SpellCheckConfiguration(
+ misspelledTextStyle: TextField.materialMisspelledTextStyle,
+ ),
+ ));
+
+ expect(tester.takeException(), isA<AssertionError>());
+ tester.binding.platformDispatcher.clearNativeSpellCheckServiceDefined();
+ });
+ });
+
group('magnifier', () {
testWidgets('should build nothing by default', (WidgetTester tester) async {
final EditableText editableText = EditableText(
@@ -13032,7 +13190,7 @@
_AccentColorTextEditingController(String text) : super(text: text);
@override
- TextSpan buildTextSpan({required BuildContext context, TextStyle? style, required bool withComposing}) {
+ TextSpan buildTextSpan({required BuildContext context, TextStyle? style, required bool withComposing, SpellCheckConfiguration? spellCheckConfiguration}) {
final Color color = Theme.of(context).colorScheme.secondary;
return super.buildTextSpan(context: context, style: TextStyle(color: color), withComposing: withComposing);
}
@@ -13041,3 +13199,5 @@
class _TestScrollController extends ScrollController {
bool get attached => hasListeners;
}
+
+class FakeSpellCheckService extends DefaultSpellCheckService {}
diff --git a/packages/flutter/test/widgets/spell_check_test.dart b/packages/flutter/test/widgets/spell_check_test.dart
new file mode 100644
index 0000000..4ac339b
--- /dev/null
+++ b/packages/flutter/test/widgets/spell_check_test.dart
@@ -0,0 +1,341 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+late TextStyle composingStyle;
+late TextStyle misspelledTextStyle;
+
+void main() {
+ setUp(() {
+ composingStyle = const TextStyle(decoration: TextDecoration.underline);
+
+ // Using Android handling for testing.
+ misspelledTextStyle = TextField.materialMisspelledTextStyle;
+ });
+
+ test(
+ 'buildTextSpanWithSpellCheckSuggestions ignores composing region when composing region out of range',
+ () {
+ const String text = 'Hello, wrold! Hey';
+ const TextEditingValue value = TextEditingValue(text: text);
+ const bool composingRegionOutOfRange = true;
+ const SpellCheckResults spellCheckResults =
+ SpellCheckResults(text, <SuggestionSpan>[
+ SuggestionSpan(
+ TextRange(start: 7, end: 12), <String>['world', 'word', 'old'])
+ ]);
+
+ final TextSpan expectedTextSpanTree = TextSpan(children: <TextSpan>[
+ const TextSpan(text: 'Hello, '),
+ TextSpan(style: misspelledTextStyle, text: 'wrold'),
+ const TextSpan(text: '! Hey')
+ ]);
+ final TextSpan textSpanTree =
+ buildTextSpanWithSpellCheckSuggestions(
+ value,
+ composingRegionOutOfRange,
+ null,
+ misspelledTextStyle,
+ spellCheckResults,
+ );
+
+ expect(textSpanTree, equals(expectedTextSpanTree));
+ });
+
+ test(
+ 'buildTextSpanWithSpellCheckSuggestions, isolated misspelled word with separate composing region example',
+ () {
+ const String text = 'Hello, wrold! Hey';
+ const TextEditingValue value = TextEditingValue(
+ text: text, composing: TextRange(start: 14, end: 17));
+ const bool composingRegionOutOfRange = false;
+ const SpellCheckResults spellCheckResults =
+ SpellCheckResults(text, <SuggestionSpan>[
+ SuggestionSpan(
+ TextRange(start: 7, end: 12), <String>['world', 'word', 'old'])
+ ]);
+
+ final TextSpan expectedTextSpanTree = TextSpan(children: <TextSpan>[
+ const TextSpan(text: 'Hello, '),
+ TextSpan(style: misspelledTextStyle, text: 'wrold'),
+ const TextSpan(text: '! '),
+ TextSpan(style: composingStyle, text: 'Hey')
+ ]);
+ final TextSpan textSpanTree =
+ buildTextSpanWithSpellCheckSuggestions(
+ value,
+ composingRegionOutOfRange,
+ null,
+ misspelledTextStyle,
+ spellCheckResults,
+ );
+
+ expect(textSpanTree, equals(expectedTextSpanTree));
+ });
+
+ test(
+ 'buildTextSpanWithSpellCheckSuggestions, composing region and misspelled words overlap example',
+ () {
+ const String text = 'Right worng worng right';
+ const TextEditingValue value = TextEditingValue(
+ text: text, composing: TextRange(start: 12, end: 17));
+ const bool composingRegionOutOfRange = false;
+ const SpellCheckResults spellCheckResults =
+ SpellCheckResults(text, <SuggestionSpan>[
+ SuggestionSpan(
+ TextRange(start: 6, end: 11), <String>['wrong', 'worn', 'wrung']),
+ SuggestionSpan(
+ TextRange(start: 12, end: 17), <String>['wrong', 'worn', 'wrung'])
+ ]);
+
+ final TextSpan expectedTextSpanTree = TextSpan(children: <TextSpan>[
+ const TextSpan(text: 'Right '),
+ TextSpan(style: misspelledTextStyle, text: 'worng'),
+ const TextSpan(text: ' '),
+ TextSpan(style: composingStyle, text: 'worng'),
+ const TextSpan(text: ' right'),
+ ]);
+ final TextSpan textSpanTree =
+ buildTextSpanWithSpellCheckSuggestions(
+ value,
+ composingRegionOutOfRange,
+ null,
+ misspelledTextStyle,
+ spellCheckResults,
+ );
+
+ expect(textSpanTree, equals(expectedTextSpanTree));
+ });
+
+ test(
+ 'buildTextSpanWithSpellCheckSuggestions, consecutive misspelled words example',
+ () {
+ const String text = 'Right worng worng right';
+ const TextEditingValue value = TextEditingValue(text: text);
+ const bool composingRegionOutOfRange = true;
+ const SpellCheckResults spellCheckResults =
+ SpellCheckResults(text, <SuggestionSpan>[
+ SuggestionSpan(
+ TextRange(start: 6, end: 11), <String>['wrong', 'worn', 'wrung']),
+ SuggestionSpan(
+ TextRange(start: 12, end: 17), <String>['wrong', 'worn', 'wrung'])
+ ]);
+
+ final TextSpan expectedTextSpanTree = TextSpan(children: <TextSpan>[
+ const TextSpan(text: 'Right '),
+ TextSpan(style: misspelledTextStyle, text: 'worng'),
+ const TextSpan(text: ' '),
+ TextSpan(style: misspelledTextStyle, text: 'worng'),
+ const TextSpan(text: ' right'),
+ ]);
+ final TextSpan textSpanTree =
+ buildTextSpanWithSpellCheckSuggestions(
+ value,
+ composingRegionOutOfRange,
+ null,
+ misspelledTextStyle,
+ spellCheckResults,
+ );
+
+ expect(textSpanTree, equals(expectedTextSpanTree));
+ });
+
+ test(
+ 'buildTextSpanWithSpellCheckSuggestions corrects results when they lag, results text shorter than actual text example',
+ () {
+ const String text = 'Hello, wrold! Hey';
+ const String resultsText = 'Hello, wrold!';
+ const TextEditingValue value = TextEditingValue(
+ text: text, composing: TextRange(start: 14, end: 17));
+ const bool composingRegionOutOfRange = false;
+ const SpellCheckResults spellCheckResults =
+ SpellCheckResults(resultsText, <SuggestionSpan>[
+ SuggestionSpan(
+ TextRange(start: 7, end: 12), <String>['world', 'word', 'old'])
+ ]);
+
+ final TextSpan expectedTextSpanTree = TextSpan(children: <TextSpan>[
+ const TextSpan(text: 'Hello, '),
+ TextSpan(style: misspelledTextStyle, text: 'wrold'),
+ const TextSpan(text: '! '),
+ TextSpan(style: composingStyle, text: 'Hey')
+ ]);
+ final TextSpan textSpanTree =
+ buildTextSpanWithSpellCheckSuggestions(
+ value,
+ composingRegionOutOfRange,
+ null,
+ misspelledTextStyle,
+ spellCheckResults,
+ );
+
+ expect(textSpanTree, equals(expectedTextSpanTree));
+ });
+
+ test(
+ 'buildTextSpanWithSpellCheckSuggestions corrects results when they lag, results text longer with more misspelled words than actual text example',
+ () {
+ const String text = 'Hello, wrold! Hey';
+ const String resultsText = 'Hello, wrold Hey feirnd!';
+ const TextEditingValue value = TextEditingValue(
+ text: text, composing: TextRange(start: 14, end: 17));
+ const bool composingRegionOutOfRange = false;
+ const SpellCheckResults spellCheckResults =
+ SpellCheckResults(resultsText, <SuggestionSpan>[
+ SuggestionSpan(
+ TextRange(start: 7, end: 12), <String>['world', 'word', 'old']),
+ SuggestionSpan(
+ TextRange(start: 17, end: 23), <String>['friend', 'fiend', 'fern'])
+ ]);
+
+ final TextSpan expectedTextSpanTree = TextSpan(children: <TextSpan>[
+ const TextSpan(text: 'Hello, '),
+ TextSpan(style: misspelledTextStyle, text: 'wrold'),
+ const TextSpan(text: '! '),
+ TextSpan(style: composingStyle, text: 'Hey')
+ ]);
+ final TextSpan textSpanTree =
+ buildTextSpanWithSpellCheckSuggestions(
+ value,
+ composingRegionOutOfRange,
+ null,
+ misspelledTextStyle,
+ spellCheckResults,
+ );
+
+ expect(textSpanTree, equals(expectedTextSpanTree));
+ });
+
+ test(
+ 'buildTextSpanWithSpellCheckSuggestions corrects results when they lag, results text mismatched example',
+ () {
+ const String text = 'Hello, wrold! Hey';
+ const String resultsText = 'Hello, wrild! Hey';
+ const TextEditingValue value = TextEditingValue(
+ text: text, composing: TextRange(start: 14, end: 17));
+ const bool composingRegionOutOfRange = false;
+ const SpellCheckResults spellCheckResults =
+ SpellCheckResults(resultsText, <SuggestionSpan>[
+ SuggestionSpan(TextRange(start: 7, end: 12), <String>['wild', 'world']),
+ ]);
+
+ final TextSpan expectedTextSpanTree = TextSpan(children: <TextSpan>[
+ const TextSpan(text: 'Hello, wrold! '),
+ TextSpan(style: composingStyle, text: 'Hey')
+ ]);
+ final TextSpan textSpanTree =
+ buildTextSpanWithSpellCheckSuggestions(
+ value,
+ composingRegionOutOfRange,
+ null,
+ misspelledTextStyle,
+ spellCheckResults,
+ );
+
+ expect(textSpanTree, equals(expectedTextSpanTree));
+ });
+
+ test(
+ 'buildTextSpanWithSpellCheckSuggestions corrects results when they lag, results shifted forward example',
+ () {
+ const String text = 'Hello, there wrold! Hey';
+ const String resultsText = 'Hello, wrold! Hey';
+ const TextEditingValue value = TextEditingValue(
+ text: text, composing: TextRange(start: 20, end: 23));
+ const bool composingRegionOutOfRange = false;
+ const SpellCheckResults spellCheckResults =
+ SpellCheckResults(resultsText, <SuggestionSpan>[
+ SuggestionSpan(
+ TextRange(start: 7, end: 12), <String>['world', 'word', 'old']),
+ ]);
+
+ final TextSpan expectedTextSpanTree = TextSpan(children: <TextSpan>[
+ const TextSpan(text: 'Hello, there '),
+ TextSpan(style: misspelledTextStyle, text: 'wrold'),
+ const TextSpan(text: '! '),
+ TextSpan(style: composingStyle, text: 'Hey')
+ ]);
+ final TextSpan textSpanTree =
+ buildTextSpanWithSpellCheckSuggestions(
+ value,
+ composingRegionOutOfRange,
+ null,
+ misspelledTextStyle,
+ spellCheckResults,
+ );
+
+ expect(textSpanTree, equals(expectedTextSpanTree));
+ });
+
+ test(
+ 'buildTextSpanWithSpellCheckSuggestions corrects results when they lag, results shifted backwards example',
+ () {
+ const String text = 'Hello, wrold! Hey';
+ const String resultsText = 'Hello, great wrold! Hey';
+ const TextEditingValue value = TextEditingValue(
+ text: text, composing: TextRange(start: 14, end: 17));
+ const bool composingRegionOutOfRange = false;
+ const SpellCheckResults spellCheckResults =
+ SpellCheckResults(resultsText, <SuggestionSpan>[
+ SuggestionSpan(
+ TextRange(start: 13, end: 18), <String>['world', 'word', 'old']),
+ ]);
+
+ final TextSpan expectedTextSpanTree = TextSpan(children: <TextSpan>[
+ const TextSpan(text: 'Hello, '),
+ TextSpan(style: misspelledTextStyle, text: 'wrold'),
+ const TextSpan(text: '! '),
+ TextSpan(style: composingStyle, text: 'Hey')
+ ]);
+ final TextSpan textSpanTree =
+ buildTextSpanWithSpellCheckSuggestions(
+ value,
+ composingRegionOutOfRange,
+ null,
+ misspelledTextStyle,
+ spellCheckResults,
+ );
+
+ expect(textSpanTree, equals(expectedTextSpanTree));
+ });
+
+ test(
+ 'buildTextSpanWithSpellCheckSuggestions corrects results when they lag, results shifted backwards and forwards example',
+ () {
+ const String text = 'Hello, wrold! And Hye!';
+ const String resultsText = 'Hello, great wrold! Hye!';
+ const TextEditingValue value = TextEditingValue(
+ text: text, composing: TextRange(start: 14, end: 17));
+ const bool composingRegionOutOfRange = false;
+ const SpellCheckResults spellCheckResults =
+ SpellCheckResults(resultsText, <SuggestionSpan>[
+ SuggestionSpan(
+ TextRange(start: 13, end: 18), <String>['world', 'word', 'old']),
+ SuggestionSpan(TextRange(start: 20, end: 23), <String>['Hey', 'He'])
+ ]);
+
+ final TextSpan expectedTextSpanTree = TextSpan(children: <TextSpan>[
+ const TextSpan(text: 'Hello, '),
+ TextSpan(style: misspelledTextStyle, text: 'wrold'),
+ const TextSpan(text: '! '),
+ TextSpan(style: composingStyle, text: 'And'),
+ const TextSpan(text: ' '),
+ TextSpan(style: misspelledTextStyle, text: 'Hye'),
+ const TextSpan(text: '!')
+ ]);
+ final TextSpan textSpanTree =
+ buildTextSpanWithSpellCheckSuggestions(
+ value,
+ composingRegionOutOfRange,
+ null,
+ misspelledTextStyle,
+ spellCheckResults,
+ );
+
+ expect(textSpanTree, equals(expectedTextSpanTree));
+ });
+}
diff --git a/packages/flutter_test/lib/src/window.dart b/packages/flutter_test/lib/src/window.dart
index e82bc80..9ceb7e6 100644
--- a/packages/flutter_test/lib/src/window.dart
+++ b/packages/flutter_test/lib/src/window.dart
@@ -311,6 +311,12 @@
}
@override
+ bool get nativeSpellCheckServiceDefined => platformDispatcher.nativeSpellCheckServiceDefined;
+ set nativeSpellCheckServiceDefinedTestValue(bool nativeSpellCheckServiceDefinedTestValue) { // ignore: avoid_setters_without_getters
+ platformDispatcher.nativeSpellCheckServiceDefinedTestValue = nativeSpellCheckServiceDefinedTestValue;
+ }
+
+ @override
bool get brieflyShowPassword => platformDispatcher.brieflyShowPassword;
/// Hides the real [brieflyShowPassword] and reports the given
/// `brieflyShowPasswordTestValue` instead.
@@ -722,6 +728,18 @@
}
@override
+ bool get nativeSpellCheckServiceDefined => _nativeSpellCheckServiceDefinedTestValue ?? _platformDispatcher.nativeSpellCheckServiceDefined;
+ bool? _nativeSpellCheckServiceDefinedTestValue;
+ set nativeSpellCheckServiceDefinedTestValue(bool nativeSpellCheckServiceDefinedTestValue) { // ignore: avoid_setters_without_getters
+ _nativeSpellCheckServiceDefinedTestValue = nativeSpellCheckServiceDefinedTestValue;
+ }
+ /// Deletes existing value that determines whether or not a native spell check
+ /// service is defined and returns to the real value.
+ void clearNativeSpellCheckServiceDefined() {
+ _nativeSpellCheckServiceDefinedTestValue = null;
+ }
+
+ @override
bool get brieflyShowPassword => _brieflyShowPasswordTestValue ?? _platformDispatcher.brieflyShowPassword;
bool? _brieflyShowPasswordTestValue;
/// Hides the real [brieflyShowPassword] and reports the given
@@ -882,6 +900,7 @@
clearLocalesTestValue();
clearSemanticsEnabledTestValue();
clearTextScaleFactorTestValue();
+ clearNativeSpellCheckServiceDefined();
}
@override