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