High contrast color scheme based on system forced colors (#165068)

This PR introduces a `bool useSystemColors` parameter to the `ThemeData`
constructor.

The goal from this PR is to enable users to easily create high contrast
themes that are based on system colors for their `MaterialApp`:

```dart
MaterialApp(
  theme: ThemeData.light(),
  darkTheme: ThemeData.dark(),
  highContrastTheme: ThemeData(useSystemColors: true, ...),
  highContrastDarkTheme: ThemeData(useSystemColors: true, ...),
)
```
The `MaterialApp` widget will automatically pick the correct one of the
4 themes based on system settings (light/dark mode, high contrast
enabled/disabled).

Depends on https://github.com/flutter/flutter/pull/164933
Closes https://github.com/flutter/flutter/issues/118853
This commit is contained in:
Mouad Debbar
2025-03-26 15:25:07 -04:00
committed by GitHub
parent 39103d9512
commit d5751be500
2 changed files with 542 additions and 2 deletions

View File

@@ -5,7 +5,7 @@
/// @docImport 'package:flutter/material.dart';
library;
import 'dart:ui' show Color, lerpDouble;
import 'dart:ui' show Color, SystemColor, SystemColorPalette, lerpDouble;
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
@@ -32,8 +32,10 @@ import 'dialog_theme.dart';
import 'divider_theme.dart';
import 'drawer_theme.dart';
import 'dropdown_menu_theme.dart';
import 'elevated_button.dart';
import 'elevated_button_theme.dart';
import 'expansion_tile_theme.dart';
import 'filled_button.dart';
import 'filled_button_theme.dart';
import 'floating_action_button_theme.dart';
import 'icon_button_theme.dart';
@@ -50,6 +52,7 @@ import 'menu_theme.dart';
import 'navigation_bar_theme.dart';
import 'navigation_drawer_theme.dart';
import 'navigation_rail_theme.dart';
import 'outlined_button.dart';
import 'outlined_button_theme.dart';
import 'page_transitions_theme.dart';
import 'popup_menu_theme.dart';
@@ -63,6 +66,7 @@ import 'slider_theme.dart';
import 'snack_bar_theme.dart';
import 'switch_theme.dart';
import 'tab_bar_theme.dart';
import 'text_button.dart';
import 'text_button_theme.dart';
import 'text_selection_theme.dart';
import 'text_theme.dart';
@@ -246,6 +250,11 @@ class ThemeData with Diagnosticable {
/// a component theme parameter like [sliderTheme], [toggleButtonsTheme],
/// or [bottomNavigationBarTheme].
///
/// When [useSystemColors] is true and the platform supports system colors, then the system colors
/// will be used to override certain theme colors. The [colorScheme], [textTheme],
/// [elevatedButtonTheme], [outlinedButtonTheme], [textButtonTheme], [filledButtonTheme], and
/// [floatingActionButtonTheme] are overriden by the system colors.
///
/// See also:
///
/// * [ThemeData.from], which creates a ThemeData from a [ColorScheme].
@@ -270,6 +279,7 @@ class ThemeData with Diagnosticable {
ScrollbarThemeData? scrollbarTheme,
InteractiveInkFeatureFactory? splashFactory,
bool? useMaterial3,
bool? useSystemColors,
VisualDensity? visualDensity,
// COLOR
ColorScheme? colorScheme,
@@ -387,6 +397,7 @@ class ThemeData with Diagnosticable {
scrollbarTheme ??= const ScrollbarThemeData();
visualDensity ??= VisualDensity.defaultDensityForPlatform(platform);
useMaterial3 ??= true;
useSystemColors ??= false;
final bool useInkSparkle = platform == TargetPlatform.android && !kIsWeb;
splashFactory ??=
useMaterial3
@@ -557,7 +568,8 @@ class ThemeData with Diagnosticable {
buttonBarTheme ??= const ButtonBarThemeData();
dialogBackgroundColor ??= isDark ? Colors.grey[800]! : Colors.white;
indicatorColor ??= colorScheme.secondary == primaryColor ? Colors.white : colorScheme.secondary;
return ThemeData.raw(
ThemeData theme = ThemeData.raw(
// For the sanity of the reader, make sure these properties are in the same
// order in every place that they are separated by section comments (e.g.
// GENERAL CONFIGURATION). Each section except for deprecations should be
@@ -651,6 +663,11 @@ class ThemeData with Diagnosticable {
dialogBackgroundColor: dialogBackgroundColor,
indicatorColor: indicatorColor,
);
if (useSystemColors) {
theme = theme._overrideWithSystemColors();
}
return theme;
}
/// Create a [ThemeData] given a set of exact values. Most values must be
@@ -1763,6 +1780,115 @@ class ThemeData with Diagnosticable {
});
}
ThemeData _overrideWithSystemColors() {
if (!SystemColor.platformProvidesSystemColors) {
return this;
}
final SystemColorPalette systemColors =
brightness == Brightness.dark ? SystemColor.dark : SystemColor.light;
ThemeData theme = this;
theme = theme.copyWith(
colorScheme: colorScheme.copyWith(
secondary: systemColors.accentColor.value,
onSecondary: systemColors.accentColorText.value,
surface: systemColors.canvas.value,
onSurface: systemColors.canvasText.value,
),
textTheme: textTheme.apply(
displayColor: systemColors.canvasText.value,
bodyColor: systemColors.canvasText.value,
),
);
final bool overrideButtons =
systemColors.buttonFace.value != null ||
systemColors.buttonBorder.value != null ||
systemColors.buttonText.value != null;
if (overrideButtons) {
theme = theme.copyWith(
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
foregroundColor: systemColors.buttonText.value,
backgroundColor: systemColors.buttonFace.value,
side:
systemColors.buttonBorder.value == null
? null
: BorderSide(color: systemColors.buttonBorder.value!),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: systemColors.buttonText.value,
backgroundColor: systemColors.buttonFace.value,
side:
systemColors.buttonBorder.value == null
? null
: BorderSide(color: systemColors.buttonBorder.value!),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: systemColors.buttonText.value,
backgroundColor: systemColors.buttonFace.value,
side:
systemColors.buttonBorder.value == null
? null
: BorderSide(color: systemColors.buttonBorder.value!),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
foregroundColor: systemColors.buttonText.value,
backgroundColor: systemColors.buttonFace.value,
side:
systemColors.buttonBorder.value == null
? null
: BorderSide(color: systemColors.buttonBorder.value!),
),
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: systemColors.buttonFace.value,
foregroundColor: systemColors.buttonText.value,
),
);
}
final bool overrideInputDecoration =
systemColors.field.value != null || systemColors.fieldText.value != null;
if (overrideInputDecoration) {
theme = theme.copyWith(
inputDecorationTheme: inputDecorationTheme.copyWith(
fillColor: systemColors.field.value,
labelStyle:
inputDecorationTheme.labelStyle?.copyWith(color: systemColors.fieldText.value) ??
TextStyle(color: systemColors.fieldText.value),
hintStyle:
inputDecorationTheme.hintStyle?.copyWith(color: systemColors.fieldText.value) ??
TextStyle(color: systemColors.fieldText.value),
helperStyle:
inputDecorationTheme.helperStyle?.copyWith(color: systemColors.fieldText.value) ??
TextStyle(color: systemColors.fieldText.value),
prefixStyle:
inputDecorationTheme.prefixStyle?.copyWith(color: systemColors.fieldText.value) ??
TextStyle(color: systemColors.fieldText.value),
suffixStyle:
inputDecorationTheme.suffixStyle?.copyWith(color: systemColors.fieldText.value) ??
TextStyle(color: systemColors.fieldText.value),
counterStyle:
inputDecorationTheme.counterStyle?.copyWith(color: systemColors.fieldText.value) ??
TextStyle(color: systemColors.fieldText.value),
),
);
}
return theme;
}
/// Linearly interpolate between two themes.
///
/// {@macro dart.ui.shadow.lerp}

View File

@@ -2,6 +2,8 @@
// 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 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -377,6 +379,418 @@ void main() {
expect(theme.applyElevationOverlayColor, false);
});
test(
'ThemeData applies light system colors when useSystemColors is true',
() {
final ThemeData theme = ThemeData(
colorSchemeSeed: Colors.orange,
brightness: Brightness.light,
useSystemColors: true,
);
expect(
theme.colorScheme.secondary,
SystemColor.light.accentColor.value,
skip: !SystemColor.light.accentColor.isSupported, // Color not always supported.
reason: 'Theme secondary color did not match system accent color',
);
expect(
theme.colorScheme.onSecondary,
SystemColor.light.accentColorText.value,
skip: !SystemColor.light.accentColorText.isSupported, // Color not always supported.
reason: 'Theme onSecondary color did not match system accent color text',
);
expect(
theme.colorScheme.surface,
SystemColor.light.canvas.value,
skip: !SystemColor.light.canvas.isSupported, // Color not always supported.
reason: 'Theme surface color did not match system canvas color',
);
expect(
theme.colorScheme.onSurface,
SystemColor.light.canvasText.value,
skip: !SystemColor.light.canvasText.isSupported, // Color not always supported.
reason: 'Theme onSurface color did not match system canvas color text',
);
// Text theme
expect(
theme.textTheme.displayLarge?.color,
SystemColor.light.canvasText.value,
skip: !SystemColor.light.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme displayLarge color did not match system text color',
);
expect(
theme.textTheme.displayMedium?.color,
SystemColor.light.canvasText.value,
skip: !SystemColor.light.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme displayMedium color did not match system text color',
);
expect(
theme.textTheme.displaySmall?.color,
SystemColor.light.canvasText.value,
skip: !SystemColor.light.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme displaySmall color did not match system text color',
);
expect(
theme.textTheme.headlineLarge?.color,
SystemColor.light.canvasText.value,
skip: !SystemColor.light.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme headlineLarge color did not match system text color',
);
expect(
theme.textTheme.headlineMedium?.color,
SystemColor.light.canvasText.value,
skip: !SystemColor.light.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme headlineMedium color did not match system text color',
);
expect(
theme.textTheme.headlineSmall?.color,
SystemColor.light.canvasText.value,
skip: !SystemColor.light.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme headlineSmall color did not match system text color',
);
expect(
theme.textTheme.titleLarge?.color,
SystemColor.light.canvasText.value,
skip: !SystemColor.light.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme titleLarge color did not match system text color',
);
expect(
theme.textTheme.titleMedium?.color,
SystemColor.light.canvasText.value,
skip: !SystemColor.light.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme titleMedium color did not match system text color',
);
expect(
theme.textTheme.titleSmall?.color,
SystemColor.light.canvasText.value,
skip: !SystemColor.light.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme titleSmall color did not match system text color',
);
expect(
theme.textTheme.bodyLarge?.color,
SystemColor.light.canvasText.value,
skip: !SystemColor.light.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme bodyLarge color did not match system text color',
);
expect(
theme.textTheme.bodyMedium?.color,
SystemColor.light.canvasText.value,
skip: !SystemColor.light.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme bodyMedium color did not match system text color',
);
expect(
theme.textTheme.bodySmall?.color,
SystemColor.light.canvasText.value,
skip: !SystemColor.light.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme bodySmall color did not match system text color',
);
expect(
theme.textTheme.labelLarge?.color,
SystemColor.light.canvasText.value,
skip: !SystemColor.light.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme labelLarge color did not match system text color',
);
expect(
theme.textTheme.labelMedium?.color,
SystemColor.light.canvasText.value,
skip: !SystemColor.light.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme labelMedium color did not match system text color',
);
expect(
theme.textTheme.labelSmall?.color,
SystemColor.light.canvasText.value,
skip: !SystemColor.light.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme labelSmall color did not match system text color',
);
// Button themes
expect(
theme.elevatedButtonTheme.style?.foregroundColor?.resolve(<WidgetState>{
WidgetState.pressed,
}),
SystemColor.light.buttonText.value,
skip: !SystemColor.light.buttonText.isSupported, // Color not always supported.
reason: 'ElevatedButtonTheme foregroundColor did not match system button text color',
);
expect(
theme.elevatedButtonTheme.style?.backgroundColor?.resolve(<WidgetState>{
WidgetState.pressed,
}),
SystemColor.light.buttonFace.value,
skip: !SystemColor.light.buttonFace.isSupported, // Color not always supported.
reason: 'ElevatedButtonTheme backgroundColor did not match system button face color',
);
expect(
theme.textButtonTheme.style?.foregroundColor?.resolve(<WidgetState>{WidgetState.pressed}),
SystemColor.light.buttonText.value,
skip: !SystemColor.light.buttonText.isSupported, // Color not always supported.
reason: 'TextButtonTheme foregroundColor did not match system button text color',
);
expect(
theme.textButtonTheme.style?.backgroundColor?.resolve(<WidgetState>{WidgetState.pressed}),
SystemColor.light.buttonFace.value,
skip: !SystemColor.light.buttonFace.isSupported, // Color not always supported.
reason: 'TextButtonTheme backgroundColor did not match system button face color',
);
expect(
theme.outlinedButtonTheme.style?.foregroundColor?.resolve(<WidgetState>{
WidgetState.pressed,
}),
SystemColor.light.buttonText.value,
skip: !SystemColor.light.buttonText.isSupported, // Color not always supported.
reason: 'OutlinedButtonTheme foregroundColor did not match system button text color',
);
expect(
theme.outlinedButtonTheme.style?.backgroundColor?.resolve(<WidgetState>{
WidgetState.pressed,
}),
SystemColor.light.buttonFace.value,
skip: !SystemColor.light.buttonFace.isSupported, // Color not always supported.
reason: 'OutlinedButtonTheme backgroundColor did not match system button face color',
);
expect(
theme.filledButtonTheme.style?.foregroundColor?.resolve(<WidgetState>{WidgetState.pressed}),
SystemColor.light.buttonText.value,
skip: !SystemColor.light.buttonText.isSupported, // Color not always supported.
reason: 'FilledButtonTheme foregroundColor did not match system button text color',
);
expect(
theme.filledButtonTheme.style?.backgroundColor?.resolve(<WidgetState>{WidgetState.pressed}),
SystemColor.light.buttonFace.value,
skip: !SystemColor.light.buttonFace.isSupported, // Color not always supported.
reason: 'FilledButtonTheme backgroundColor did not match system button face color',
);
expect(
theme.floatingActionButtonTheme.foregroundColor,
SystemColor.light.buttonText.value,
skip: !SystemColor.light.buttonFace.isSupported, // Color not always supported.
reason: 'FloatingActionButtonTheme foregroundColor did not match system button text color',
);
expect(
theme.floatingActionButtonTheme.backgroundColor,
SystemColor.light.buttonFace.value,
skip: !SystemColor.light.buttonFace.isSupported, // Color not always supported.
reason: 'FloatingActionButtonTheme backgroundColor did not match system button face color',
);
},
// Only run this test on platforms that provide system colors.
skip: !SystemColor.platformProvidesSystemColors,
);
test(
'ThemeData applies dark system colors when useSystemColors is true',
() {
final ThemeData theme = ThemeData(
colorSchemeSeed: Colors.orange,
brightness: Brightness.dark,
useSystemColors: true,
);
expect(
theme.colorScheme.secondary,
SystemColor.dark.accentColor.value,
skip: !SystemColor.dark.accentColor.isSupported, // Color not always supported.
reason: 'Theme secondary color did not match system accent color',
);
expect(
theme.colorScheme.onSecondary,
SystemColor.dark.accentColorText.value,
skip: !SystemColor.dark.accentColorText.isSupported, // Color not always supported.
reason: 'Theme onSecondary color did not match system accent color text',
);
expect(
theme.colorScheme.surface,
SystemColor.dark.canvas.value,
skip: !SystemColor.dark.canvas.isSupported, // Color not always supported.
reason: 'Theme surface color did not match system canvas color',
);
expect(
theme.colorScheme.onSurface,
SystemColor.dark.canvasText.value,
skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported.
reason: 'Theme onSurface color did not match system canvas color text',
);
// Text theme
expect(
theme.textTheme.displayLarge?.color,
SystemColor.dark.canvasText.value,
skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme displayLarge color did not match system text color',
);
expect(
theme.textTheme.displayMedium?.color,
SystemColor.dark.canvasText.value,
skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme displayMedium color did not match system text color',
);
expect(
theme.textTheme.displaySmall?.color,
SystemColor.dark.canvasText.value,
skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme displaySmall color did not match system text color',
);
expect(
theme.textTheme.headlineLarge?.color,
SystemColor.dark.canvasText.value,
skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme headlineLarge color did not match system text color',
);
expect(
theme.textTheme.headlineMedium?.color,
SystemColor.dark.canvasText.value,
skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme headlineMedium color did not match system text color',
);
expect(
theme.textTheme.headlineSmall?.color,
SystemColor.dark.canvasText.value,
skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme headlineSmall color did not match system text color',
);
expect(
theme.textTheme.titleLarge?.color,
SystemColor.dark.canvasText.value,
skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme titleLarge color did not match system text color',
);
expect(
theme.textTheme.titleMedium?.color,
SystemColor.dark.canvasText.value,
skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme titleMedium color did not match system text color',
);
expect(
theme.textTheme.titleSmall?.color,
SystemColor.dark.canvasText.value,
skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme titleSmall color did not match system text color',
);
expect(
theme.textTheme.bodyLarge?.color,
SystemColor.dark.canvasText.value,
skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme bodyLarge color did not match system text color',
);
expect(
theme.textTheme.bodyMedium?.color,
SystemColor.dark.canvasText.value,
skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme bodyMedium color did not match system text color',
);
expect(
theme.textTheme.bodySmall?.color,
SystemColor.dark.canvasText.value,
skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme bodySmall color did not match system text color',
);
expect(
theme.textTheme.labelLarge?.color,
SystemColor.dark.canvasText.value,
skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme labelLarge color did not match system text color',
);
expect(
theme.textTheme.labelMedium?.color,
SystemColor.dark.canvasText.value,
skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme labelMedium color did not match system text color',
);
expect(
theme.textTheme.labelSmall?.color,
SystemColor.dark.canvasText.value,
skip: !SystemColor.dark.canvasText.isSupported, // Color not always supported.
reason: 'TextTheme labelSmall color did not match system text color',
);
// Button themes
expect(
theme.elevatedButtonTheme.style?.foregroundColor?.resolve(<WidgetState>{
WidgetState.pressed,
}),
SystemColor.dark.buttonText.value,
skip: !SystemColor.dark.buttonText.isSupported, // Color not always supported.
reason: 'ElevatedButtonTheme foregroundColor did not match system button text color',
);
expect(
theme.elevatedButtonTheme.style?.backgroundColor?.resolve(<WidgetState>{
WidgetState.pressed,
}),
SystemColor.dark.buttonFace.value,
skip: !SystemColor.dark.buttonFace.isSupported, // Color not always supported.
reason: 'ElevatedButtonTheme backgroundColor did not match system button face color',
);
expect(
theme.textButtonTheme.style?.foregroundColor?.resolve(<WidgetState>{WidgetState.pressed}),
SystemColor.dark.buttonText.value,
skip: !SystemColor.dark.buttonText.isSupported, // Color not always supported.
reason: 'TextButtonTheme foregroundColor did not match system button text color',
);
expect(
theme.textButtonTheme.style?.backgroundColor?.resolve(<WidgetState>{WidgetState.pressed}),
SystemColor.dark.buttonFace.value,
skip: !SystemColor.dark.buttonFace.isSupported, // Color not always supported.
reason: 'TextButtonTheme backgroundColor did not match system button face color',
);
expect(
theme.outlinedButtonTheme.style?.foregroundColor?.resolve(<WidgetState>{
WidgetState.pressed,
}),
SystemColor.dark.buttonText.value,
skip: !SystemColor.dark.buttonText.isSupported, // Color not always supported.
reason: 'OutlinedButtonTheme foregroundColor did not match system button text color',
);
expect(
theme.outlinedButtonTheme.style?.backgroundColor?.resolve(<WidgetState>{
WidgetState.pressed,
}),
SystemColor.dark.buttonFace.value,
skip: !SystemColor.dark.buttonFace.isSupported, // Color not always supported.
reason: 'OutlinedButtonTheme backgroundColor did not match system button face color',
);
expect(
theme.filledButtonTheme.style?.foregroundColor?.resolve(<WidgetState>{WidgetState.pressed}),
SystemColor.dark.buttonText.value,
skip: !SystemColor.dark.buttonText.isSupported, // Color not always supported.
reason: 'FilledButtonTheme foregroundColor did not match system button text color',
);
expect(
theme.filledButtonTheme.style?.backgroundColor?.resolve(<WidgetState>{WidgetState.pressed}),
SystemColor.dark.buttonFace.value,
skip: !SystemColor.dark.buttonFace.isSupported, // Color not always supported.
reason: 'FilledButtonTheme backgroundColor did not match system button face color',
);
expect(
theme.floatingActionButtonTheme.foregroundColor,
SystemColor.dark.buttonText.value,
skip: !SystemColor.dark.buttonFace.isSupported, // Color not always supported.
reason: 'FloatingActionButtonTheme foregroundColor did not match system button text color',
);
expect(
theme.floatingActionButtonTheme.backgroundColor,
SystemColor.dark.buttonFace.value,
skip: !SystemColor.dark.buttonFace.isSupported, // Color not always supported.
reason: 'FloatingActionButtonTheme backgroundColor did not match system button face color',
);
},
// Only run this test on platforms that provide system colors.
skip: !SystemColor.platformProvidesSystemColors,
);
test(
'ThemeData.light() can generate a default M3 light colorScheme when useMaterial3 is true',
() {