diff --git a/packages/devtools_app/benchmark/web_bundle_size_test.dart b/packages/devtools_app/benchmark/web_bundle_size_test.dart index 1e17beb7506..d1635d89911 100644 --- a/packages/devtools_app/benchmark/web_bundle_size_test.dart +++ b/packages/devtools_app/benchmark/web_bundle_size_test.dart @@ -11,7 +11,7 @@ import 'package:path/path.dart' as path; import 'package:test/test.dart'; // Benchmark size in kB. -const bundleSizeBenchmark = 5550; +const bundleSizeBenchmark = 5560; const gzipBundleSizeBenchmark = 1650; void main() { diff --git a/packages/devtools_app/lib/src/app.dart b/packages/devtools_app/lib/src/app.dart index 43c72cd6b7c..620a42c4bc9 100644 --- a/packages/devtools_app/lib/src/app.dart +++ b/packages/devtools_app/lib/src/app.dart @@ -23,6 +23,8 @@ import 'framework/notifications_view.dart'; import 'framework/observer/disconnect_observer.dart'; import 'framework/release_notes.dart'; import 'framework/scaffold/scaffold.dart'; +import 'screens/accessibility/accessibility_controller.dart'; +import 'screens/accessibility/accessibility_screen.dart'; import 'screens/app_size/app_size_controller.dart'; import 'screens/app_size/app_size_screen.dart'; import 'screens/debugger/debugger_controller.dart'; @@ -731,6 +733,11 @@ List defaultScreens({ DTDToolsScreen(), createController: (_) => DTDToolsController(), ), + if (FeatureFlags.accessibility.isEnabled) + DevToolsScreen( + AccessibilityScreen(), + createController: (_) => AccessibilityController(), + ), ]; } diff --git a/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart b/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart new file mode 100644 index 00000000000..c067e4e6ef8 --- /dev/null +++ b/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart @@ -0,0 +1,46 @@ +// Copyright 2025 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/utils.dart'; +import 'package:flutter/foundation.dart'; + +import '../../shared/framework/screen.dart'; +import '../../shared/framework/screen_controllers.dart'; + +class AccessibilityController extends DevToolsScreenController + with AutoDisposeControllerMixin { + @override + String get screenId => ScreenMetaData.accessibility.id; + + ValueListenable get accessibilityEnabled => _accessibilityEnabled; + final _accessibilityEnabled = ValueNotifier(false); + + ValueListenable get textScaleFactor => _textScaleFactor; + final _textScaleFactor = ValueNotifier(1.0); + + ValueListenable get highContrastEnabled => _highContrastEnabled; + final _highContrastEnabled = ValueNotifier(false); + + ValueListenable get autoAuditEnabled => _autoAuditEnabled; + final _autoAuditEnabled = ValueNotifier(false); + + void setTextScaleFactor(double factor) { + _textScaleFactor.value = factor; + // TODO(chunhtai): set text scale factor on device. + } + + void toggleHighContrast(bool enable) { + _highContrastEnabled.value = enable; + // TODO(chunhtai): set high contrast on device. + } + + void toggleAutoAudit(bool enable) { + _autoAuditEnabled.value = enable; + // TODO(chunhtai): auto run audit when enabled. + } + + void runAudit() { + // TODO(chunhtai): run accessibility audit. + } +} diff --git a/packages/devtools_app/lib/src/screens/accessibility/accessibility_controls.dart b/packages/devtools_app/lib/src/screens/accessibility/accessibility_controls.dart new file mode 100644 index 00000000000..3d23cdc9b39 --- /dev/null +++ b/packages/devtools_app/lib/src/screens/accessibility/accessibility_controls.dart @@ -0,0 +1,116 @@ +// Copyright 2025 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/ui.dart'; +import 'package:flutter/material.dart'; + +import '../../shared/globals.dart'; +import 'accessibility_controller.dart'; + +class AccessibilityControls extends StatelessWidget { + const AccessibilityControls({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const AreaPaneHeader(title: Text('Settings & Controls')), + Expanded( + child: ListView( + padding: const EdgeInsets.all(defaultSpacing), + children: const [ + _SystemSimulationControls(), + SizedBox(height: defaultSpacing), + _AuditControls(), + ], + ), + ), + ], + ); + } +} + +class _SystemSimulationControls extends StatelessWidget { + const _SystemSimulationControls(); + + @override + Widget build(BuildContext context) { + final controller = screenControllers.lookup(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'SYSTEM SIMULATION', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: denseSpacing), + ValueListenableBuilder( + valueListenable: controller.highContrastEnabled, + builder: (context, enabled, _) { + return SwitchListTile( + title: const Text('High Contrast Mode'), + value: enabled, + onChanged: (value) => controller.toggleHighContrast(value), + ); + }, + ), + const SizedBox(height: denseSpacing), + ValueListenableBuilder( + valueListenable: controller.textScaleFactor, + builder: (context, factor, _) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Text Scale Factor: ${factor.toStringAsFixed(1)}x'), + Slider( + value: factor, + min: 0.5, + max: 3.0, + divisions: 25, + label: factor.toStringAsFixed(1), + onChanged: (value) => controller.setTextScaleFactor(value), + ), + ], + ); + }, + ), + ], + ); + } +} + +class _AuditControls extends StatelessWidget { + const _AuditControls(); + + @override + Widget build(BuildContext context) { + final controller = screenControllers.lookup(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('AUDIT CONTROLS', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: denseSpacing), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + icon: const Icon(Icons.play_arrow), + label: const Text('Run Audit'), + onPressed: () => controller.runAudit(), + ), + ), + const SizedBox(height: denseSpacing), + ValueListenableBuilder( + valueListenable: controller.autoAuditEnabled, + builder: (context, enabled, _) { + return SwitchListTile( + title: const Text('Auto-run Audit'), + value: enabled, + onChanged: (value) => controller.toggleAutoAudit(value), + ); + }, + ), + ], + ); + } +} diff --git a/packages/devtools_app/lib/src/screens/accessibility/accessibility_results.dart b/packages/devtools_app/lib/src/screens/accessibility/accessibility_results.dart new file mode 100644 index 00000000000..5a2a7b649cb --- /dev/null +++ b/packages/devtools_app/lib/src/screens/accessibility/accessibility_results.dart @@ -0,0 +1,27 @@ +// Copyright 2025 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/ui.dart'; +import 'package:flutter/material.dart'; + +class AccessibilityResults extends StatelessWidget { + const AccessibilityResults({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const AreaPaneHeader(title: Text('Audit Results')), + Expanded( + child: ListView.builder( + itemCount: 0, + itemBuilder: (context, index) { + return const ListTile(title: Text('Violation Placeholder')); + }, + ), + ), + ], + ); + } +} diff --git a/packages/devtools_app/lib/src/screens/accessibility/accessibility_screen.dart b/packages/devtools_app/lib/src/screens/accessibility/accessibility_screen.dart new file mode 100644 index 00000000000..8b09d44eac2 --- /dev/null +++ b/packages/devtools_app/lib/src/screens/accessibility/accessibility_screen.dart @@ -0,0 +1,50 @@ +// Copyright 2025 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/ui.dart'; +import 'package:flutter/material.dart'; + +import '../../shared/framework/screen.dart'; +import '../../shared/globals.dart'; +import 'accessibility_controller.dart'; +import 'accessibility_controls.dart'; +import 'accessibility_results.dart'; + +class AccessibilityScreen extends Screen { + AccessibilityScreen() : super.fromMetaData(ScreenMetaData.accessibility); + + static final id = ScreenMetaData.accessibility.id; + + @override + Widget buildScreenBody(BuildContext context) { + return const AccessibilityScreenBody(); + } +} + +class AccessibilityScreenBody extends StatefulWidget { + const AccessibilityScreenBody({super.key}); + + @override + State createState() => + _AccessibilityScreenBodyState(); +} + +class _AccessibilityScreenBodyState extends State { + late final AccessibilityController controller; + + @override + void initState() { + super.initState(); + controller = screenControllers.lookup(); + } + + @override + Widget build(BuildContext context) { + return SplitPane( + axis: Axis.horizontal, + initialFractions: const [0.3, 0.7], + children: const [AccessibilityControls(), AccessibilityResults()], + ); + } +} diff --git a/packages/devtools_app/lib/src/shared/feature_flags.dart b/packages/devtools_app/lib/src/shared/feature_flags.dart index 88a8b248878..1050e0d206e 100644 --- a/packages/devtools_app/lib/src/shared/feature_flags.dart +++ b/packages/devtools_app/lib/src/shared/feature_flags.dart @@ -93,6 +93,14 @@ extension FeatureFlags on Never { enabled: enableExperiments, ); + /// Flag to enable the Accessibility screen. + /// + /// https://github.com/flutter/devtools/issues/9687 + static final accessibility = BooleanFeatureFlag( + name: 'accessibility', + enabled: enableExperiments, + ); + /// A set of all the boolean feature flags for debugging purposes. /// /// When adding a new boolean flag, you are responsible for adding it to this @@ -104,6 +112,7 @@ extension FeatureFlags on Never { dapDebugging, inspectorV2, aiAssistant, + accessibility, }; /// A set of all the Flutter channel feature flags for debugging purposes. diff --git a/packages/devtools_app/lib/src/shared/framework/screen.dart b/packages/devtools_app/lib/src/shared/framework/screen.dart index 0cdc840653a..d28a02730d7 100644 --- a/packages/devtools_app/lib/src/shared/framework/screen.dart +++ b/packages/devtools_app/lib/src/shared/framework/screen.dart @@ -129,6 +129,11 @@ enum ScreenMetaData { requiresAdvancedDeveloperMode: true, requiresConnection: false, ), + accessibility( + 'accessibility', + title: 'Accessibility', + icon: Icons.accessibility_new_rounded, + ), simple('simple'); const ScreenMetaData( diff --git a/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart b/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart new file mode 100644 index 00000000000..f388bdfd76a --- /dev/null +++ b/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart @@ -0,0 +1,73 @@ +// Copyright 2024 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/src/app.dart'; +import 'package:devtools_app/src/screens/accessibility/accessibility_controller.dart'; +import 'package:devtools_app/src/screens/accessibility/accessibility_controls.dart'; +import 'package:devtools_app/src/screens/accessibility/accessibility_results.dart'; +import 'package:devtools_app/src/screens/accessibility/accessibility_screen.dart'; +import 'package:devtools_app/src/service/service_manager.dart'; +import 'package:devtools_app/src/shared/feature_flags.dart'; +import 'package:devtools_app/src/shared/managers/banner_messages.dart'; +import 'package:devtools_app/src/shared/managers/notifications.dart'; +import 'package:devtools_app/src/shared/offline/offline_data.dart'; +import 'package:devtools_app/src/shared/preferences/preferences.dart'; +import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:devtools_test/helpers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('AccessibilityScreen', () { + late FakeServiceConnectionManager fakeServiceConnection; + late AccessibilityController controller; + + setUp(() { + fakeServiceConnection = FakeServiceConnectionManager(); + setGlobal(ServiceConnectionManager, fakeServiceConnection); + setGlobal(IdeTheme, IdeTheme()); + setGlobal(OfflineDataController, OfflineDataController()); + setGlobal(NotificationService, NotificationService()); + setGlobal(BannerMessagesController, BannerMessagesController()); + setGlobal(PreferencesController, PreferencesController()); + FeatureFlags.accessibility.setEnabledForTests(true); + controller = AccessibilityController(); + }); + + tearDown(() { + FeatureFlags.accessibility.setEnabledForTests(false); + }); + + testWidgets('builds its body', (WidgetTester tester) async { + await tester.pumpWidget( + wrapWithControllers( + Builder(builder: (context) => AccessibilityScreen().build(context)), + accessibility: controller, + ), + ); + expect(find.byType(AccessibilityScreenBody), findsOneWidget); + expect(find.byType(AccessibilityControls), findsOneWidget); + expect(find.byType(AccessibilityResults), findsOneWidget); + }); + + test('is included in defaultScreens when enabled', () { + devtoolsScreens = null; + expect( + defaultScreens().any((s) => s.screen is AccessibilityScreen), + isTrue, + ); + }); + + test('is invalid in defaultScreens when disabled', () { + FeatureFlags.accessibility.setEnabledForTests(false); + devtoolsScreens = null; + expect( + defaultScreens().any((s) => s.screen is AccessibilityScreen), + isFalse, + ); + }); + }); +} diff --git a/packages/devtools_test/lib/src/helpers/wrappers.dart b/packages/devtools_test/lib/src/helpers/wrappers.dart index 20f16ad8817..0f7b5a45069 100644 --- a/packages/devtools_test/lib/src/helpers/wrappers.dart +++ b/packages/devtools_test/lib/src/helpers/wrappers.dart @@ -8,6 +8,7 @@ library; import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/screens/accessibility/accessibility_controller.dart'; import 'package:devtools_app/src/shared/primitives/query_parameters.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; @@ -96,6 +97,7 @@ Widget wrapWithControllers( ReleaseNotesController? releaseNotes, VMDeveloperToolsController? vmDeveloperTools, DTDToolsController? dtdTools, + AccessibilityController? accessibility, bool includeRouter = true, DevToolsQueryParams? queryParams, }) { @@ -173,6 +175,12 @@ Widget wrapWithControllers( offline: offline, ); } + if (accessibility != null) { + screenControllers.register( + () => accessibility, + offline: offline, + ); + } var child = wrapWithNotifications(widget); final providers = [