Support brightness override in a11y page (#9898)
* Update accessibility_controller.dart
1
* 1
Update overrides_pane.dart
Update accessibility_controller.dart
tests
Update service_extensions.dart
* resolve comments
diff --git a/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart b/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart
index 887ddf5..1fd71a3 100644
--- a/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart
+++ b/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart
@@ -2,21 +2,31 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+import 'dart:async';
+
+import 'package:devtools_app_shared/service.dart';
import 'package:devtools_app_shared/utils.dart';
import 'package:flutter/foundation.dart';
+import '../../service/service_extensions.dart' as extensions;
import '../../shared/framework/screen.dart';
import '../../shared/framework/screen_controllers.dart';
+import '../../shared/globals.dart';
/// Modes for brightness override in the accessibility controls.
enum BrightnessOverride {
- system('System Default'),
- light('Light Mode'),
- dark('Dark Mode');
+ system('System Default', 'system'),
+ light('Light Mode', 'Brightness.light'),
+ dark('Dark Mode', 'Brightness.dark');
- const BrightnessOverride(this.display);
+ const BrightnessOverride(this.display, this.value);
+ /// The user-facing display label for this override option.
final String display;
+
+ /// The raw value associated with this override option sent to or received
+ /// from the VM service extension.
+ final String value;
}
/// Controller for the Accessibility screen.
@@ -26,6 +36,12 @@
_initListeners();
}
+ @override
+ void init() {
+ super.init();
+ _initServiceExtensionStates();
+ }
+
void _initListeners() {
addAutoDisposeListener(brightness, _onBrightnessChanged);
addAutoDisposeListener(textScale, _onTextScaleChanged);
@@ -34,9 +50,34 @@
addAutoDisposeListener(highContrast, _onHighContrastChanged);
}
+ void _initServiceExtensionStates() {
+ final state = serviceConnection.serviceManager.serviceExtensionManager
+ .getServiceExtensionState(extensions.brightnessMode.extension);
+
+ void updateFromDeviceState(ServiceExtensionState state) {
+ final newBrightness = !state.enabled || state.value == null
+ ? BrightnessOverride.system
+ : BrightnessOverride.values.firstWhere(
+ (b) => b.value == state.value,
+ orElse: () => BrightnessOverride.system,
+ );
+ brightness.value = newBrightness;
+ }
+
+ updateFromDeviceState(state.value);
+ addAutoDisposeListener(state, () => updateFromDeviceState(state.value));
+ }
+
void _onBrightnessChanged() {
- // TODO(hannah-hyj): Implement VM service extension call for brightness override.
- // e.g. using 'ext.flutter.brightnessOverride'.
+ final value = brightness.value;
+ unawaited(
+ serviceConnection.serviceManager.serviceExtensionManager
+ .setServiceExtensionState(
+ extensions.brightnessMode.extension,
+ enabled: value != BrightnessOverride.system,
+ value: value.value,
+ ),
+ );
}
void _onTextScaleChanged() {
diff --git a/packages/devtools_app/lib/src/service/service_extensions.dart b/packages/devtools_app/lib/src/service/service_extensions.dart
index 7e20efa..b2bf785 100644
--- a/packages/devtools_app/lib/src/service/service_extensions.dart
+++ b/packages/devtools_app/lib/src/service/service_extensions.dart
@@ -379,6 +379,16 @@
tooltip: 'Override Target Platform',
);
+/// Service extension description for overriding brightness in accessibility controls.
+final brightnessMode = ServiceExtensionDescription<String>.from(
+ extensions.brightnessMode,
+ title: 'Override Brightness',
+ iconData: Icons.brightness_6,
+ gaScreenName: gac.accessibility,
+ gaItem: gac.brightnessOverride,
+ tooltip: 'Override Brightness',
+);
+
final disableClipLayers = ToggleableServiceExtensionDescription<bool>.from(
extensions.disableClipLayers,
title: 'Render Clip layers',
diff --git a/packages/devtools_app/lib/src/shared/analytics/constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants.dart
index 3d29326..c56654f 100644
--- a/packages/devtools_app/lib/src/shared/analytics/constants.dart
+++ b/packages/devtools_app/lib/src/shared/analytics/constants.dart
@@ -44,6 +44,7 @@
const console = 'console';
final simple = ScreenMetaData.simple.id;
final deeplink = ScreenMetaData.deepLinks.id;
+final accessibility = ScreenMetaData.accessibility.id;
// GA events not associated with a any screen e.g., hotReload, hotRestart, etc
const devToolsMain = 'main';
@@ -86,6 +87,7 @@
const repaintRainbowDocs = 'repaintRainbowDocs';
const debugBanner = 'debugBanner';
const togglePlatform = 'togglePlatform';
+const brightnessOverride = 'brightnessOverride';
const highlightOversizedImages = 'highlightOversizedImages';
const highlightOversizedImagesDocs = 'highlightOversizedImagesDocs';
const selectWidgetMode = 'selectWidgetMode';
diff --git a/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart b/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart
new file mode 100644
index 0000000..1363f9d
--- /dev/null
+++ b/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart
@@ -0,0 +1,118 @@
+// Copyright 2026 The Flutter Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+
+@TestOn('vm')
+library;
+
+import 'package:devtools_app/devtools_app.dart';
+import 'package:devtools_app_shared/utils.dart';
+import 'package:devtools_test/devtools_test.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mockito/mockito.dart';
+
+void main() {
+ group('AccessibilityController', () {
+ late AccessibilityController controller;
+
+ setUp(() {
+ final fakeServiceConnection = FakeServiceConnectionManager();
+ when(
+ fakeServiceConnection.serviceManager.connectedApp!.isFlutterWebAppNow,
+ ).thenReturn(false);
+ when(
+ fakeServiceConnection.serviceManager.connectedApp!.isProfileBuildNow,
+ ).thenReturn(false);
+
+ setGlobal(NotificationService, NotificationService());
+ setGlobal(
+ DevToolsEnvironmentParameters,
+ ExternalDevToolsEnvironmentParameters(),
+ );
+ setGlobal(PreferencesController, PreferencesController());
+ setGlobal(ServiceConnectionManager, fakeServiceConnection);
+
+ controller = AccessibilityController()..init();
+ });
+
+ test('initial state', () {
+ expect(controller.brightness.value, BrightnessOverride.system);
+ });
+
+ test(
+ 'service extension state change updates controller brightness state',
+ () {
+ final fakeServiceExtensionManager =
+ serviceConnection.serviceManager.serviceExtensionManager
+ as FakeServiceExtensionManager;
+
+ expect(controller.brightness.value, BrightnessOverride.system);
+
+ // Simulate service extension state change from device to dark mode
+ fakeServiceExtensionManager.fakeServiceExtensionStateChanged(
+ brightnessMode.extension,
+ 'Brightness.dark',
+ );
+ expect(controller.brightness.value, BrightnessOverride.dark);
+
+ // Simulate service extension state change from device to light mode
+ fakeServiceExtensionManager.fakeServiceExtensionStateChanged(
+ brightnessMode.extension,
+ 'Brightness.light',
+ );
+ expect(controller.brightness.value, BrightnessOverride.light);
+
+ // Simulate service extension state change from device to system
+ fakeServiceExtensionManager.fakeServiceExtensionStateChanged(
+ brightnessMode.extension,
+ 'system',
+ );
+ expect(controller.brightness.value, BrightnessOverride.system);
+ },
+ );
+
+ test(
+ 'setting controller brightness updates service extension state',
+ () async {
+ final fakeServiceExtensionManager =
+ serviceConnection.serviceManager.serviceExtensionManager
+ as FakeServiceExtensionManager;
+
+ // Initial state
+ expect(controller.brightness.value, BrightnessOverride.system);
+
+ // Set to dark mode
+ controller.brightness.value = BrightnessOverride.dark;
+
+ // Wait for async operations to complete
+ await Future<void>.delayed(Duration.zero);
+
+ final darkState = fakeServiceExtensionManager
+ .getServiceExtensionState(brightnessMode.extension)
+ .value;
+ expect(darkState.value, equals('Brightness.dark'));
+ expect(darkState.enabled, isTrue);
+
+ // Set to light mode
+ controller.brightness.value = BrightnessOverride.light;
+ await Future<void>.delayed(Duration.zero);
+
+ final lightState = fakeServiceExtensionManager
+ .getServiceExtensionState(brightnessMode.extension)
+ .value;
+ expect(lightState.value, equals('Brightness.light'));
+ expect(lightState.enabled, isTrue);
+
+ // Set to system
+ controller.brightness.value = BrightnessOverride.system;
+ await Future<void>.delayed(Duration.zero);
+
+ final systemState = fakeServiceExtensionManager
+ .getServiceExtensionState(brightnessMode.extension)
+ .value;
+ expect(systemState.value, equals('system'));
+ expect(systemState.enabled, isFalse);
+ },
+ );
+ });
+}
diff --git a/packages/devtools_app_shared/lib/src/service/service_extensions.dart b/packages/devtools_app_shared/lib/src/service/service_extensions.dart
index cada4b0..608c7c8 100644
--- a/packages/devtools_app_shared/lib/src/service/service_extensions.dart
+++ b/packages/devtools_app_shared/lib/src/service/service_extensions.dart
@@ -201,6 +201,13 @@
values: ['iOS', 'android', 'fuchsia', 'macOS', 'linux'],
);
+/// Override the platform brightness (light mode, dark mode, or system default).
+final brightnessMode = ServiceExtension<String>(
+ extension:
+ '$flutterExtensionPrefix${FoundationServiceExtensions.brightnessOverride.name}',
+ values: ['system', 'Brightness.light', 'Brightness.dark'],
+);
+
/// Toggle whether interacting with the device selects widgets or triggers
/// normal interactions.
final toggleSelectWidgetMode = ToggleableServiceExtension<bool>(
@@ -259,6 +266,7 @@
toggleSelectWidgetMode,
countWidgetBuilds,
profilePlatformChannels,
+ brightnessMode,
];
/// Service extensions that are not safe to call unless a frame has already
@@ -281,6 +289,7 @@
enableOnDeviceInspector,
togglePlatformMode,
slowAnimations,
+ brightnessMode,
].map((extension) => extension.extension),
);
diff --git a/packages/devtools_app_shared/test/service/service_extensions_test.dart b/packages/devtools_app_shared/test/service/service_extensions_test.dart
new file mode 100644
index 0000000..2ee39d2
--- /dev/null
+++ b/packages/devtools_app_shared/test/service/service_extensions_test.dart
@@ -0,0 +1,32 @@
+// Copyright 2026 The Flutter Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+
+import 'package:devtools_app_shared/service_extensions.dart';
+import 'package:test/test.dart';
+
+void main() {
+ group('ServiceExtensions', () {
+ test('brightnessMode is properly configured', () {
+ expect(
+ brightnessMode.extension,
+ equals('ext.flutter.brightnessOverride'),
+ );
+ expect(
+ brightnessMode.values,
+ equals(['system', 'Brightness.light', 'Brightness.dark']),
+ );
+ expect(
+ serviceExtensionsAllowlist[brightnessMode.extension],
+ equals(brightnessMode),
+ );
+ });
+
+ test('brightnessMode is in unsafe before first frame set', () {
+ expect(
+ isUnsafeBeforeFirstFlutterFrame('ext.flutter.brightnessOverride'),
+ isTrue,
+ );
+ });
+ });
+}