Introduce Theme extensions (#98033)

* first pass

* x

* x

* address feedback

* support multiple extensions

* add convenience function, Object ⇒ dynamic, lerping

* remove not-useful comment

* fix examples/api lower sdk constraint

* remove trailing spaces

* remove another pesky trailing space

* improve lerp

* address feedback

* hide map implementation from constructor and copyWith

* use iterableproperty

* Revert "hide map implementation from constructor and copyWith"

This reverts commit a6994af0046e3c90dbc9405cac628feb5b2d3031.

* slow down sample

* make theme extension params required

* add null check

* improve documentation

* fix hashCode and operator == overrides

* modify existing tests

* remove trailing spaces

* add all tests except lerping

* fix lerping bug

* add toString to themeExtension example

* add lerping test

* assume non-nullability in example

* address feedback

* update docs

* remove trailing space

* use Map.unmodifiable
diff --git a/examples/api/lib/material/theme/theme_extension.1.dart b/examples/api/lib/material/theme/theme_extension.1.dart
new file mode 100644
index 0000000..6ea2bb8
--- /dev/null
+++ b/examples/api/lib/material/theme/theme_extension.1.dart
@@ -0,0 +1,126 @@
+// 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.
+
+// Flutter code sample for ThemeExtension
+
+import 'package:flutter/material.dart';
+import 'package:flutter/scheduler.dart';
+
+@immutable
+class MyColors extends ThemeExtension<MyColors> {
+  const MyColors({
+    required this.blue,
+    required this.red,
+  });
+
+  final Color? blue;
+  final Color? red;
+
+  @override
+  MyColors copyWith({Color? red, Color? blue}) {
+    return MyColors(
+      blue: blue ?? this.blue,
+      red: red ?? this.red,
+    );
+  }
+
+  @override
+  MyColors lerp(ThemeExtension<MyColors>? other, double t) {
+    if (other is! MyColors) {
+      return this;
+    }
+    return MyColors(
+      blue: Color.lerp(blue, other.blue, t),
+      red: Color.lerp(red, other.red, t),
+    );
+  }
+
+  // Optional
+  @override
+  String toString() => 'MyColors(blue: $blue, red: $red)';
+}
+
+void main() {
+  // Slow down time to see lerping.
+  timeDilation = 5.0;
+  runApp(const MyApp());
+}
+
+class MyApp extends StatefulWidget {
+  const MyApp({Key? key}) : super(key: key);
+
+  static const String _title = 'Flutter Code Sample';
+
+  @override
+  State<MyApp> createState() => _MyAppState();
+}
+
+class _MyAppState extends State<MyApp> {
+  bool isLightTheme = true;
+
+  void toggleTheme() {
+    setState(() => isLightTheme = !isLightTheme);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return MaterialApp(
+      title: MyApp._title,
+      theme: ThemeData.light().copyWith(
+        extensions: <ThemeExtension<dynamic>>{
+          const MyColors(
+            blue: Color(0xFF1E88E5),
+            red: Color(0xFFE53935),
+          ),
+        },
+      ),
+      darkTheme: ThemeData.dark().copyWith(
+        extensions: <ThemeExtension<dynamic>>{
+          const MyColors(
+            blue: Color(0xFF90CAF9),
+            red: Color(0xFFEF9A9A),
+          ),
+        },
+      ),
+      themeMode: isLightTheme ? ThemeMode.light : ThemeMode.dark,
+      home: Home(
+        isLightTheme: isLightTheme,
+        toggleTheme: toggleTheme,
+      ),
+    );
+  }
+}
+
+class Home extends StatelessWidget {
+  const Home({
+    Key? key,
+    required this.isLightTheme,
+    required this.toggleTheme,
+  }) : super(key: key);
+
+  final bool isLightTheme;
+  final void Function() toggleTheme;
+
+  @override
+  Widget build(BuildContext context) {
+    final MyColors myColors = Theme.of(context).extension<MyColors>()!;
+    return Material(
+      child: Center(
+        child: Row(
+          mainAxisAlignment: MainAxisAlignment.center,
+          children: <Widget>[
+            Container(width: 100, height: 100, color: myColors.blue),
+            const SizedBox(width: 10),
+            Container(width: 100, height: 100, color: myColors.red),
+            const SizedBox(width: 50),
+            IconButton(
+              icon: Icon(isLightTheme ? Icons.nightlight : Icons.wb_sunny),
+              onPressed: toggleTheme,
+            ),
+          ],
+        )
+      ),
+    );
+  }
+}
diff --git a/examples/api/pubspec.yaml b/examples/api/pubspec.yaml
index a7bc468..b9b4c64 100644
--- a/examples/api/pubspec.yaml
+++ b/examples/api/pubspec.yaml
@@ -5,7 +5,7 @@
 version: 1.0.0
 
 environment:
-  sdk: ">=2.14.0-383.0.dev <3.0.0"
+  sdk: ">=2.17.0-0 <3.0.0"
   flutter: ">=2.5.0-6.0.pre.30 <3.0.0"
 
 dependencies:
diff --git a/packages/flutter/lib/src/material/theme_data.dart b/packages/flutter/lib/src/material/theme_data.dart
index b2cc31c..d83002b 100644
--- a/packages/flutter/lib/src/material/theme_data.dart
+++ b/packages/flutter/lib/src/material/theme_data.dart
@@ -53,6 +53,36 @@
 
 export 'package:flutter/services.dart' show Brightness;
 
+/// An interface that defines custom additions to a [ThemeData] object.
+///
+/// Typically used for custom colors. To use, subclass [ThemeExtension],
+/// define a number of fields (e.g. [Color]s), and implement the [copyWith] and
+/// [lerp] methods. The latter will ensure smooth transitions of properties when
+/// switching themes.
+///
+/// {@tool dartpad}
+/// This sample shows how to create and use a subclass of [ThemeExtension] that
+/// defines two colors.
+///
+/// ** See code in examples/api/lib/material/theme/theme_extension.1.dart **
+/// {@end-tool}
+abstract class ThemeExtension<T extends ThemeExtension<T>> {
+  /// Enable const constructor for subclasses.
+  const ThemeExtension();
+
+  /// The extension's type.
+  Object get type => T;
+
+  /// Creates a copy of this theme extension with the given fields
+  /// replaced by the non-null parameter values.
+  ThemeExtension<T> copyWith();
+
+  /// Linearly interpolate with another [ThemeExtension] object.
+  ///
+  /// {@macro dart.ui.shadow.lerp}
+  ThemeExtension<T> lerp(ThemeExtension<T>? other, double t);
+}
+
 // Deriving these values is black magic. The spec claims that pressed buttons
 // have a highlight of 0x66999999, but that's clearly wrong. The videos in the
 // spec show that buttons have a composited highlight of #E1E1E1 on a background
@@ -243,6 +273,7 @@
     AndroidOverscrollIndicator? androidOverscrollIndicator,
     bool? applyElevationOverlayColor,
     NoDefaultCupertinoThemeData? cupertinoOverrideTheme,
+    Iterable<ThemeExtension<dynamic>>? extensions,
     InputDecorationTheme? inputDecorationTheme,
     MaterialTapTargetSize? materialTapTargetSize,
     PageTransitionsTheme? pageTransitionsTheme,
@@ -390,6 +421,7 @@
   }) {
     // GENERAL CONFIGURATION
     cupertinoOverrideTheme = cupertinoOverrideTheme?.noDefault();
+    extensions ??= <ThemeExtension<dynamic>>[];
     inputDecorationTheme ??= const InputDecorationTheme();
     platform ??= defaultTargetPlatform;
     switch (platform) {
@@ -562,6 +594,7 @@
       androidOverscrollIndicator: androidOverscrollIndicator,
       applyElevationOverlayColor: applyElevationOverlayColor,
       cupertinoOverrideTheme: cupertinoOverrideTheme,
+      extensions: _themeExtensionIterableToMap(extensions),
       inputDecorationTheme: inputDecorationTheme,
       materialTapTargetSize: materialTapTargetSize,
       pageTransitionsTheme: pageTransitionsTheme,
@@ -665,6 +698,7 @@
     required this.androidOverscrollIndicator,
     required this.applyElevationOverlayColor,
     required this.cupertinoOverrideTheme,
+    required this.extensions,
     required this.inputDecorationTheme,
     required this.materialTapTargetSize,
     required this.pageTransitionsTheme,
@@ -807,6 +841,7 @@
     required this.primaryColorBrightness,
   }) : // GENERAL CONFIGURATION
        assert(applyElevationOverlayColor != null),
+       assert(extensions != null),
        assert(inputDecorationTheme != null),
        assert(materialTapTargetSize != null),
        assert(pageTransitionsTheme != null),
@@ -1053,6 +1088,32 @@
   /// can be overridden using attributes of this [cupertinoOverrideTheme].
   final NoDefaultCupertinoThemeData? cupertinoOverrideTheme;
 
+  /// Arbitrary additions to this theme.
+  ///
+  /// To define extensions, pass an [Iterable] containing one or more [ThemeExtension]
+  /// subclasses to [ThemeData.new] or [copyWith].
+  ///
+  /// To obtain an extension, use [extension].
+  ///
+  /// {@tool dartpad}
+  /// This sample shows how to create and use a subclass of [ThemeExtension] that
+  /// defines two colors.
+  ///
+  /// ** See code in examples/api/lib/material/theme/theme_extension.1.dart **
+  /// {@end-tool}
+  ///
+  /// See also:
+  ///
+  /// * [extension], a convenience function for obtaining a specific extension.
+  final Map<Object, ThemeExtension<dynamic>> extensions;
+
+  /// Used to obtain a particular [ThemeExtension] from [extensions].
+  ///
+  /// Obtain with `Theme.of(context).extension<MyThemeExtension>()`.
+  ///
+  /// See [extensions] for an interactive example.
+  T? extension<T>() => extensions[T] as T;
+
   /// The default [InputDecoration] values for [InputDecorator], [TextField],
   /// and [TextFormField] are based on this theme.
   ///
@@ -1588,6 +1649,7 @@
     AndroidOverscrollIndicator? androidOverscrollIndicator,
     bool? applyElevationOverlayColor,
     NoDefaultCupertinoThemeData? cupertinoOverrideTheme,
+    Iterable<ThemeExtension<dynamic>>? extensions,
     InputDecorationTheme? inputDecorationTheme,
     MaterialTapTargetSize? materialTapTargetSize,
     PageTransitionsTheme? pageTransitionsTheme,
@@ -1736,6 +1798,7 @@
       androidOverscrollIndicator: androidOverscrollIndicator ?? this.androidOverscrollIndicator,
       applyElevationOverlayColor: applyElevationOverlayColor ?? this.applyElevationOverlayColor,
       cupertinoOverrideTheme: cupertinoOverrideTheme ?? this.cupertinoOverrideTheme,
+      extensions: (extensions != null) ? _themeExtensionIterableToMap(extensions) : this.extensions,
       inputDecorationTheme: inputDecorationTheme ?? this.inputDecorationTheme,
       materialTapTargetSize: materialTapTargetSize ?? this.materialTapTargetSize,
       pageTransitionsTheme: pageTransitionsTheme ?? this.pageTransitionsTheme,
@@ -1889,6 +1952,34 @@
     return Brightness.dark;
   }
 
+  /// Linearly interpolate between two [extensions].
+  ///
+  /// Includes all theme extensions in [a] and [b].
+  ///
+  /// {@macro dart.ui.shadow.lerp}
+  static Map<Object, ThemeExtension<dynamic>> _lerpThemeExtensions(ThemeData a, ThemeData b, double t) {
+    // Lerp [a].
+    final Map<Object, ThemeExtension<dynamic>> newExtensions = a.extensions.map((Object id, ThemeExtension<dynamic> extensionA) {
+        final ThemeExtension<dynamic>? extensionB = b.extensions[id];
+        return MapEntry<Object, ThemeExtension<dynamic>>(id, extensionA.lerp(extensionB, t));
+      });
+    // Add [b]-only extensions.
+    newExtensions.addEntries(b.extensions.entries.where(
+      (MapEntry<Object, ThemeExtension<dynamic>> entry) =>
+          !a.extensions.containsKey(entry.key)));
+
+    return newExtensions;
+  }
+
+  /// Convert the [extensionsIterable] passed to [ThemeData.new] or [copyWith]
+  /// to the stored [extensions] map, where each entry's key consists of the extension's type.
+  static Map<Object, ThemeExtension<dynamic>> _themeExtensionIterableToMap(Iterable<ThemeExtension<dynamic>> extensionsIterable) {
+    return Map<Object, ThemeExtension<dynamic>>.unmodifiable(<Object, ThemeExtension<dynamic>>{
+      // Strangely, the cast is necessary for tests to run.
+      for (final ThemeExtension<dynamic> extension in extensionsIterable) extension.type: extension as ThemeExtension<ThemeExtension<dynamic>>
+    });
+  }
+
   /// Linearly interpolate between two themes.
   ///
   /// The arguments must not be null.
@@ -1906,6 +1997,7 @@
       androidOverscrollIndicator:t < 0.5 ? a.androidOverscrollIndicator : b.androidOverscrollIndicator,
       applyElevationOverlayColor:t < 0.5 ? a.applyElevationOverlayColor : b.applyElevationOverlayColor,
       cupertinoOverrideTheme:t < 0.5 ? a.cupertinoOverrideTheme : b.cupertinoOverrideTheme,
+      extensions: _lerpThemeExtensions(a, b, t),
       inputDecorationTheme:t < 0.5 ? a.inputDecorationTheme : b.inputDecorationTheme,
       materialTapTargetSize:t < 0.5 ? a.materialTapTargetSize : b.materialTapTargetSize,
       pageTransitionsTheme:t < 0.5 ? a.pageTransitionsTheme : b.pageTransitionsTheme,
@@ -2006,6 +2098,7 @@
         other.androidOverscrollIndicator == androidOverscrollIndicator &&
         other.applyElevationOverlayColor == applyElevationOverlayColor &&
         other.cupertinoOverrideTheme == cupertinoOverrideTheme &&
+        mapEquals(other.extensions, extensions) &&
         other.inputDecorationTheme == inputDecorationTheme &&
         other.materialTapTargetSize == materialTapTargetSize &&
         other.pageTransitionsTheme == pageTransitionsTheme &&
@@ -2103,6 +2196,8 @@
       androidOverscrollIndicator,
       applyElevationOverlayColor,
       cupertinoOverrideTheme,
+      hashList(extensions.keys),
+      hashList(extensions.values),
       inputDecorationTheme,
       materialTapTargetSize,
       pageTransitionsTheme,
@@ -2200,6 +2295,7 @@
     properties.add(EnumProperty<AndroidOverscrollIndicator>('androidOverscrollIndicator', androidOverscrollIndicator, defaultValue: null, level: DiagnosticLevel.debug));
     properties.add(DiagnosticsProperty<bool>('applyElevationOverlayColor', applyElevationOverlayColor, level: DiagnosticLevel.debug));
     properties.add(DiagnosticsProperty<NoDefaultCupertinoThemeData>('cupertinoOverrideTheme', cupertinoOverrideTheme, defaultValue: defaultData.cupertinoOverrideTheme, level: DiagnosticLevel.debug));
+    properties.add(IterableProperty<ThemeExtension<dynamic>>('extensions', extensions.values, defaultValue: defaultData.extensions.values, level: DiagnosticLevel.debug));
     properties.add(DiagnosticsProperty<InputDecorationTheme>('inputDecorationTheme', inputDecorationTheme, level: DiagnosticLevel.debug));
     properties.add(DiagnosticsProperty<MaterialTapTargetSize>('materialTapTargetSize', materialTapTargetSize, level: DiagnosticLevel.debug));
     properties.add(DiagnosticsProperty<PageTransitionsTheme>('pageTransitionsTheme', pageTransitionsTheme, level: DiagnosticLevel.debug));
diff --git a/packages/flutter/test/material/theme_data_test.dart b/packages/flutter/test/material/theme_data_test.dart
index 3fca00c..c44f718 100644
--- a/packages/flutter/test/material/theme_data_test.dart
+++ b/packages/flutter/test/material/theme_data_test.dart
@@ -6,6 +6,62 @@
 import 'package:flutter/material.dart';
 import 'package:flutter_test/flutter_test.dart';
 
+@immutable
+class MyThemeExtensionA extends ThemeExtension<MyThemeExtensionA> {
+  const MyThemeExtensionA({
+    required this.color1,
+    required this.color2,
+  });
+
+  final Color? color1;
+  final Color? color2;
+
+  @override
+  MyThemeExtensionA copyWith({Color? color1, Color? color2}) {
+    return MyThemeExtensionA(
+      color1: color1 ?? this.color1,
+      color2: color2 ?? this.color2,
+    );
+  }
+
+  @override
+  MyThemeExtensionA lerp(ThemeExtension<MyThemeExtensionA>? other, double t) {
+    if (other is! MyThemeExtensionA) {
+      return this;
+    }
+    return MyThemeExtensionA(
+      color1: Color.lerp(color1, other.color1, t),
+      color2: Color.lerp(color2, other.color2, t),
+    );
+  }
+}
+
+@immutable
+class MyThemeExtensionB extends ThemeExtension<MyThemeExtensionB> {
+  const MyThemeExtensionB({
+    required this.textStyle,
+  });
+
+  final TextStyle? textStyle;
+
+  @override
+  MyThemeExtensionB copyWith({Color? color, TextStyle? textStyle}) {
+    return MyThemeExtensionB(
+      textStyle: textStyle ?? this.textStyle,
+    );
+  }
+
+  @override
+  MyThemeExtensionB lerp(ThemeExtension<MyThemeExtensionB>? other, double t) {
+    if (other is! MyThemeExtensionB) {
+      return this;
+    }
+    return MyThemeExtensionB(
+      textStyle: TextStyle.lerp(textStyle, other.textStyle, t),
+    );
+  }
+}
+
 void main() {
   test('Theme data control test', () {
     final ThemeData dark = ThemeData.dark();
@@ -377,6 +433,136 @@
     expect(expanded.maxHeight, equals(double.infinity));
   });
 
+  group('Theme extensions', () {
+    const Key containerKey = Key('container');
+
+    testWidgets('can be obtained', (WidgetTester tester) async {
+      await tester.pumpWidget(
+        MaterialApp(
+          theme: ThemeData(
+            extensions: const <ThemeExtension<dynamic>>{
+              MyThemeExtensionA(
+                color1: Colors.black,
+                color2: Colors.amber,
+              ),
+              MyThemeExtensionB(
+                textStyle: TextStyle(fontSize: 50),
+              )
+            },
+          ),
+          home: Container(key: containerKey),
+        ),
+      );
+
+      final ThemeData theme = Theme.of(
+        tester.element(find.byKey(containerKey)),
+      );
+
+      expect(theme.extension<MyThemeExtensionA>()!.color1, Colors.black);
+      expect(theme.extension<MyThemeExtensionA>()!.color2, Colors.amber);
+      expect(theme.extension<MyThemeExtensionB>()!.textStyle, const TextStyle(fontSize: 50));
+    });
+
+    testWidgets('can use copyWith', (WidgetTester tester) async {
+      await tester.pumpWidget(
+        MaterialApp(
+          theme: ThemeData(
+            extensions: <ThemeExtension<dynamic>>{
+              const MyThemeExtensionA(
+                color1: Colors.black,
+                color2: Colors.amber,
+              ).copyWith(color1: Colors.blue),
+            },
+          ),
+          home: Container(key: containerKey),
+        ),
+      );
+
+      final ThemeData theme = Theme.of(
+        tester.element(find.byKey(containerKey)),
+      );
+
+      expect(theme.extension<MyThemeExtensionA>()!.color1, Colors.blue);
+      expect(theme.extension<MyThemeExtensionA>()!.color2, Colors.amber);
+    });
+
+    testWidgets('can lerp', (WidgetTester tester) async {
+      const MyThemeExtensionA extensionA1 = MyThemeExtensionA(
+        color1: Colors.black,
+        color2: Colors.amber,
+      );
+      const MyThemeExtensionA extensionA2 = MyThemeExtensionA(
+        color1: Colors.white,
+        color2: Colors.blue,
+      );
+      const MyThemeExtensionB extensionB1 = MyThemeExtensionB(
+        textStyle: TextStyle(fontSize: 50),
+      );
+      const MyThemeExtensionB extensionB2 = MyThemeExtensionB(
+        textStyle: TextStyle(fontSize: 100),
+      );
+
+      // Both ThemeDatas include both extensions
+      ThemeData lerped = ThemeData.lerp(
+        ThemeData(
+          extensions: const <ThemeExtension<dynamic>>[
+            extensionA1,
+            extensionB1,
+          ],
+        ),
+        ThemeData(
+          extensions: const <ThemeExtension<dynamic>>{
+            extensionA2,
+            extensionB2,
+          },
+        ),
+        0.5,
+      );
+
+      expect(lerped.extension<MyThemeExtensionA>()!.color1, const Color(0xff7f7f7f));
+      expect(lerped.extension<MyThemeExtensionA>()!.color2, const Color(0xff90ab7d));
+      expect(lerped.extension<MyThemeExtensionB>()!.textStyle, const TextStyle(fontSize: 75));
+
+      // Missing from 2nd ThemeData
+      lerped = ThemeData.lerp(
+        ThemeData(
+          extensions: const <ThemeExtension<dynamic>>{
+            extensionA1,
+            extensionB1,
+          },
+        ),
+        ThemeData(
+          extensions: const <ThemeExtension<dynamic>>{
+            extensionB2,
+          },
+        ),
+        0.5,
+      );
+      expect(lerped.extension<MyThemeExtensionA>()!.color1, Colors.black); // Not lerped
+      expect(lerped.extension<MyThemeExtensionA>()!.color2, Colors.amber); // Not lerped
+      expect(lerped.extension<MyThemeExtensionB>()!.textStyle, const TextStyle(fontSize: 75));
+
+      // Missing from 1st ThemeData
+      lerped = ThemeData.lerp(
+        ThemeData(
+          extensions: const <ThemeExtension<dynamic>>{
+            extensionA1,
+          },
+        ),
+        ThemeData(
+          extensions: const <ThemeExtension<dynamic>>{
+            extensionA2,
+            extensionB2,
+          },
+        ),
+        0.5,
+      );
+      expect(lerped.extension<MyThemeExtensionA>()!.color1, const Color(0xff7f7f7f));
+      expect(lerped.extension<MyThemeExtensionA>()!.color2, const Color(0xff90ab7d));
+      expect(lerped.extension<MyThemeExtensionB>()!.textStyle, const TextStyle(fontSize: 100)); // Not lerped
+    });
+  });
+
   test('copyWith, ==, hashCode basics', () {
     expect(ThemeData(), ThemeData().copyWith());
     expect(ThemeData().hashCode, ThemeData().copyWith().hashCode);
@@ -506,6 +692,7 @@
       fixTextFieldOutlineLabel: false,
       useTextSelectionTheme: false,
       androidOverscrollIndicator: null,
+      extensions: const <Object, ThemeExtension<dynamic>>{},
     );
 
     final SliderThemeData otherSliderTheme = SliderThemeData.fromPrimaryColors(
@@ -606,6 +793,9 @@
       fixTextFieldOutlineLabel: true,
       useTextSelectionTheme: true,
       androidOverscrollIndicator: AndroidOverscrollIndicator.stretch,
+      extensions: const <Object, ThemeExtension<dynamic>>{
+        MyThemeExtensionB: MyThemeExtensionB(textStyle: TextStyle()),
+      },
     );
 
     final ThemeData themeDataCopy = theme.copyWith(
@@ -685,6 +875,7 @@
       drawerTheme: otherTheme.drawerTheme,
       listTileTheme: otherTheme.listTileTheme,
       fixTextFieldOutlineLabel: otherTheme.fixTextFieldOutlineLabel,
+      extensions: otherTheme.extensions.values,
     );
 
     expect(themeDataCopy.brightness, equals(otherTheme.brightness));
@@ -763,6 +954,7 @@
     expect(themeDataCopy.drawerTheme, equals(otherTheme.drawerTheme));
     expect(themeDataCopy.listTileTheme, equals(otherTheme.listTileTheme));
     expect(themeDataCopy.fixTextFieldOutlineLabel, equals(otherTheme.fixTextFieldOutlineLabel));
+    expect(themeDataCopy.extensions, equals(otherTheme.extensions));
   });
 
   testWidgets('ThemeData.toString has less than 200 characters output', (WidgetTester tester) async {
@@ -810,6 +1002,7 @@
       'androidOverscrollIndicator',
       'applyElevationOverlayColor',
       'cupertinoOverrideTheme',
+      'extensions',
       'inputDecorationTheme',
       'materialTapTargetSize',
       'pageTransitionsTheme',