diff --git a/packages/devtools_app/lib/src/app.dart b/packages/devtools_app/lib/src/app.dart index 68f9b19fd2c..79a1f660880 100644 --- a/packages/devtools_app/lib/src/app.dart +++ b/packages/devtools_app/lib/src/app.dart @@ -10,7 +10,7 @@ import 'package:provider/provider.dart'; import 'example/conditional_screen.dart'; import 'extensions/extension_model.dart'; -import 'extensions/ui/extension_screen.dart'; +import 'extensions/extension_screen.dart'; import 'framework/framework_core.dart'; import 'framework/home_screen.dart'; import 'framework/initializer.dart'; diff --git a/packages/devtools_app/lib/src/extensions/embedded/_controller_desktop.dart b/packages/devtools_app/lib/src/extensions/embedded/_controller_desktop.dart new file mode 100644 index 00000000000..8118b83d56e --- /dev/null +++ b/packages/devtools_app/lib/src/extensions/embedded/_controller_desktop.dart @@ -0,0 +1,9 @@ +// Copyright 2023 The Chromium 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 'controller.dart'; + +class EmbeddedExtensionControllerImpl extends EmbeddedExtensionController { + // TODO(kenz): implement desktop stubs that match the web implementation. +} diff --git a/packages/devtools_app/lib/src/extensions/embedded/_controller_web.dart b/packages/devtools_app/lib/src/extensions/embedded/_controller_web.dart new file mode 100644 index 00000000000..cb0d0575779 --- /dev/null +++ b/packages/devtools_app/lib/src/extensions/embedded/_controller_web.dart @@ -0,0 +1,9 @@ +// Copyright 2023 The Chromium 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 'controller.dart'; + +class EmbeddedExtensionControllerImpl extends EmbeddedExtensionController { + // TODO(kenz): implement web controller for embedded extension +} diff --git a/packages/devtools_app/lib/src/extensions/embedded/_view_desktop.dart b/packages/devtools_app/lib/src/extensions/embedded/_view_desktop.dart new file mode 100644 index 00000000000..649f8d7a1d8 --- /dev/null +++ b/packages/devtools_app/lib/src/extensions/embedded/_view_desktop.dart @@ -0,0 +1,24 @@ +// Copyright 2023 The Chromium 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 'controller.dart'; + +class EmbeddedExtension extends StatelessWidget { + const EmbeddedExtension({super.key, required this.controller}); + + final EmbeddedExtensionController controller; + + @override + Widget build(BuildContext context) { + // TODO(kenz): if web view support for desktop is ever added, use that here. + return const Center( + child: Text( + 'Cannot display the DevTools plugin.' + ' IFrames are not supported on desktop platforms.', + ), + ); + } +} diff --git a/packages/devtools_app/lib/src/extensions/embedded/_view_web.dart b/packages/devtools_app/lib/src/extensions/embedded/_view_web.dart new file mode 100644 index 00000000000..857b4ea673b --- /dev/null +++ b/packages/devtools_app/lib/src/extensions/embedded/_view_web.dart @@ -0,0 +1,20 @@ +// Copyright 2023 The Chromium 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 'controller.dart'; + +class EmbeddedExtension extends StatelessWidget { + const EmbeddedExtension({super.key, required this.controller}); + + final EmbeddedExtensionController controller; + + @override + Widget build(BuildContext context) { + return const Center( + child: Text('TODO implement embedded extension web view'), + ); + } +} diff --git a/packages/devtools_app/lib/src/extensions/embedded/controller.dart b/packages/devtools_app/lib/src/extensions/embedded/controller.dart new file mode 100644 index 00000000000..25415e4095a --- /dev/null +++ b/packages/devtools_app/lib/src/extensions/embedded/controller.dart @@ -0,0 +1,12 @@ +// Copyright 2023 The Chromium 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 '../../shared/primitives/auto_dispose.dart'; +import '_controller_desktop.dart' if (dart.library.html) '_controller_web.dart'; + +EmbeddedExtensionControllerImpl createEmbeddedExtensionController() { + return EmbeddedExtensionControllerImpl(); +} + +abstract class EmbeddedExtensionController extends DisposableController {} diff --git a/packages/devtools_app/lib/src/extensions/embedded/view.dart b/packages/devtools_app/lib/src/extensions/embedded/view.dart new file mode 100644 index 00000000000..5677d21dad3 --- /dev/null +++ b/packages/devtools_app/lib/src/extensions/embedded/view.dart @@ -0,0 +1,27 @@ +// Copyright 2023 The Chromium 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 '_view_desktop.dart' if (dart.library.html) '_view_web.dart'; +import 'controller.dart'; + +/// A widget that displays a DevTools extension in an embedded iFrame. +/// +/// A DevTools extension is provided by a pub package and is served by the +/// DevTools server when present for a connected application. +/// +/// When DevTools is run on Desktop for development, this widget displays a +/// placeholder, since Flutter Desktop does not currently support web views. +class EmbeddedExtensionView extends StatelessWidget { + const EmbeddedExtensionView({Key? key, required this.controller}) + : super(key: key); + + final EmbeddedExtensionController controller; + + @override + Widget build(BuildContext context) { + return EmbeddedExtension(controller: controller); + } +} diff --git a/packages/devtools_app/lib/src/extensions/extension_screen.dart b/packages/devtools_app/lib/src/extensions/extension_screen.dart new file mode 100644 index 00000000000..0833be08d6a --- /dev/null +++ b/packages/devtools_app/lib/src/extensions/extension_screen.dart @@ -0,0 +1,146 @@ +// Copyright 2023 The Chromium 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/material.dart'; + +import '../shared/analytics/constants.dart' as gac; +import '../shared/common_widgets.dart'; +import '../shared/primitives/listenable.dart'; +import '../shared/primitives/utils.dart'; +import '../shared/screen.dart'; +import '../shared/theme.dart'; +import 'embedded/controller.dart'; +import 'embedded/view.dart'; +import 'extension_model.dart'; + +class ExtensionScreen extends Screen { + ExtensionScreen(this.extensionConfig) + : super.conditional( + // TODO(kenz): we may need to ensure this is a unique id. + id: '${extensionConfig.name}-ext', + title: extensionConfig.name.toSentenceCase(), + icon: extensionConfig.icon, + // TODO(kenz): support static DevTools extensions. + requiresConnection: true, + ); + + final DevToolsExtensionConfig extensionConfig; + + @override + ValueListenable get showIsolateSelector => + const FixedValueListenable(true); + + @override + Widget build(BuildContext context) => + _ExtensionScreenBody(extensionConfig: extensionConfig); +} + +class _ExtensionScreenBody extends StatefulWidget { + const _ExtensionScreenBody({required this.extensionConfig}); + + final DevToolsExtensionConfig extensionConfig; + + @override + State<_ExtensionScreenBody> createState() => __ExtensionScreenBodyState(); +} + +class __ExtensionScreenBodyState extends State<_ExtensionScreenBody> { + late final EmbeddedExtensionController extensionController; + + @override + void initState() { + super.initState(); + extensionController = createEmbeddedExtensionController(); + } + + @override + void dispose() { + extensionController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ExtensionView( + controller: extensionController, + extension: widget.extensionConfig, + ); + } +} + +class ExtensionView extends StatelessWidget { + const ExtensionView({ + super.key, + required this.controller, + required this.extension, + }); + + final EmbeddedExtensionController controller; + + final DevToolsExtensionConfig extension; + + @override + Widget build(BuildContext context) { + return RoundedOutlinedBorder( + clip: true, + child: Column( + children: [ + EmbeddedExtensionHeader(extension: extension), + Expanded( + child: KeepAliveWrapper( + child: Center( + child: EmbeddedExtensionView(controller: controller), + ), + ), + ), + ], + ), + ); + } +} + +// TODO(kenz): add button to deactivate extension once activate / deactivate +// logic is hooked up. +class EmbeddedExtensionHeader extends StatelessWidget { + const EmbeddedExtensionHeader({super.key, required this.extension}); + + final DevToolsExtensionConfig extension; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final extensionName = extension.name.toLowerCase(); + return AreaPaneHeader( + title: RichText( + text: TextSpan( + text: 'package:$extensionName extension', + style: theme.regularTextStyle.copyWith(fontWeight: FontWeight.bold), + children: [ + TextSpan( + text: ' (v${extension.version})', + style: theme.subtleTextStyle, + ), + ], + ), + ), + includeTopBorder: false, + roundedTopBorder: false, + rightPadding: defaultSpacing, + actions: [ + RichText( + text: LinkTextSpan( + link: Link( + display: 'Report an issue', + url: extension.issueTrackerLink, + gaScreenName: gac.extensionScreenId, + gaSelectedItemDescription: gac.extensionFeedback(extensionName), + ), + context: context, + ), + ), + ], + ); + } +} diff --git a/packages/devtools_app/lib/src/extensions/ui/extension_screen.dart b/packages/devtools_app/lib/src/extensions/ui/extension_screen.dart deleted file mode 100644 index f8d15a74c21..00000000000 --- a/packages/devtools_app/lib/src/extensions/ui/extension_screen.dart +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2023 The Chromium 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/material.dart'; - -import '../../shared/primitives/listenable.dart'; -import '../../shared/screen.dart'; -import '../extension_model.dart'; - -class ExtensionScreen extends Screen { - ExtensionScreen(this.extensionConfig) - : super.conditional( - // TODO(kenz): we may need to ensure this is a unique id. - id: extensionConfig.name, - title: extensionConfig.name, - icon: extensionConfig.icon, - // TODO(kenz): support static DevTools extensions. - requiresConnection: true, - ); - - final DevToolsExtensionConfig extensionConfig; - - @override - ValueListenable get showIsolateSelector => - const FixedValueListenable(true); - - @override - Widget build(BuildContext context) => - Text('TODO: iFrame for ${extensionConfig.name}'); -} diff --git a/packages/devtools_app/lib/src/shared/analytics/constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants.dart index a29a0080592..cc11bb6f88a 100644 --- a/packages/devtools_app/lib/src/shared/analytics/constants.dart +++ b/packages/devtools_app/lib/src/shared/analytics/constants.dart @@ -41,6 +41,10 @@ const feedbackButton = 'feedbackButton'; const contributingLink = 'contributing'; const discordLink = 'discord'; +// Extension screens UX actions. +const extensionScreenId = 'devtoolsExtension'; +String extensionFeedback(String name) => 'extensionFeedback-$name'; + // Inspector UX actions: const refresh = 'refresh'; const refreshEmptyTree = 'refreshEmptyTree'; diff --git a/packages/devtools_app/lib/src/shared/screen.dart b/packages/devtools_app/lib/src/shared/screen.dart index 6f43d976505..201cac8314e 100644 --- a/packages/devtools_app/lib/src/shared/screen.dart +++ b/packages/devtools_app/lib/src/shared/screen.dart @@ -187,7 +187,7 @@ abstract class Screen { text: TextSpan(text: title), textDirection: TextDirection.ltr, )..layout(); - const measurementBuffer = 1.5; + const measurementBuffer = 2.0; return painter.width + denseSpacing + defaultIconSize + diff --git a/packages/devtools_app/test/extensions/extension_screen_test.dart b/packages/devtools_app/test/extensions/extension_screen_test.dart new file mode 100644 index 00000000000..cc5fc29dde5 --- /dev/null +++ b/packages/devtools_app/test/extensions/extension_screen_test.dart @@ -0,0 +1,107 @@ +// Copyright 2023 The Chromium 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:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/extensions/embedded/view.dart'; +import 'package:devtools_app/src/extensions/extension_model.dart'; +import 'package:devtools_app/src/extensions/extension_screen.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const windowSize = Size(2000.0, 2000.0); + group('Extension screen', () { + late ExtensionScreen fooScreen; + late ExtensionScreen barScreen; + late ExtensionScreen providerScreen; + + setUp(() { + setGlobal(IdeTheme, IdeTheme()); + setGlobal(PreferencesController, PreferencesController()); + setGlobal(ServiceConnectionManager, ServiceConnectionManager()); + + fooScreen = ExtensionScreen(fooExtension); + barScreen = ExtensionScreen(barExtension); + providerScreen = ExtensionScreen(providerExtension); + }); + + testWidgets('builds its tab', (WidgetTester tester) async { + await tester.pumpWidget(wrap(Builder(builder: fooScreen.buildTab))); + expect(find.text('Foo'), findsOneWidget); + expect(find.byIcon(fooExtension.icon), findsOneWidget); + + await tester.pumpWidget(wrap(Builder(builder: barScreen.buildTab))); + expect(find.text('Bar'), findsOneWidget); + expect(find.byIcon(barExtension.icon), findsOneWidget); + + await tester.pumpWidget(wrap(Builder(builder: providerScreen.buildTab))); + expect(find.text('Provider'), findsOneWidget); + expect(find.byIcon(providerExtension.icon), findsOneWidget); + }); + + testWidgetsWithWindowSize( + 'renders as expected', + windowSize, + (tester) async { + await tester.pumpWidget(wrap(Builder(builder: fooScreen.build))); + expect(find.byType(ExtensionView), findsOneWidget); + expect(find.byType(EmbeddedExtensionHeader), findsOneWidget); + expect( + find.richTextContaining('package:foo extension'), + findsOneWidget, + ); + expect(find.richTextContaining('(v1.0.0)'), findsOneWidget); + expect(find.richTextContaining('Report an issue'), findsOneWidget); + expect(find.byType(EmbeddedExtensionView), findsOneWidget); + + await tester.pumpWidget(wrap(Builder(builder: barScreen.build))); + expect(find.byType(ExtensionView), findsOneWidget); + expect(find.byType(EmbeddedExtensionHeader), findsOneWidget); + expect( + find.richTextContaining('package:bar extension'), + findsOneWidget, + ); + expect(find.richTextContaining('(v2.0.0)'), findsOneWidget); + expect(find.richTextContaining('Report an issue'), findsOneWidget); + expect(find.byType(EmbeddedExtensionView), findsOneWidget); + + await tester.pumpWidget(wrap(Builder(builder: providerScreen.build))); + expect(find.byType(ExtensionView), findsOneWidget); + expect(find.byType(EmbeddedExtensionHeader), findsOneWidget); + expect( + find.richTextContaining('package:provider extension'), + findsOneWidget, + ); + expect(find.richTextContaining('(v3.0.0)'), findsOneWidget); + expect(find.richTextContaining('Report an issue'), findsOneWidget); + expect(find.byType(EmbeddedExtensionView), findsOneWidget); + }, + ); + }); +} + +final fooExtension = DevToolsExtensionConfig.parse({ + DevToolsExtensionConfig.nameKey: 'Foo', + DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', + DevToolsExtensionConfig.versionKey: '1.0.0', + DevToolsExtensionConfig.pathKey: '/path/to/foo', +}); + +final barExtension = DevToolsExtensionConfig.parse({ + DevToolsExtensionConfig.nameKey: 'bar', + DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', + DevToolsExtensionConfig.versionKey: '2.0.0', + DevToolsExtensionConfig.materialIconCodePointKey: 0xe638, + DevToolsExtensionConfig.pathKey: '/path/to/bar', +}); + +final providerExtension = DevToolsExtensionConfig.parse({ + DevToolsExtensionConfig.nameKey: 'provider', + DevToolsExtensionConfig.issueTrackerKey: + 'https://github.com/rrousselGit/provider/issues', + DevToolsExtensionConfig.versionKey: '3.0.0', + DevToolsExtensionConfig.materialIconCodePointKey: 0xe50a, + DevToolsExtensionConfig.pathKey: '/path/to/provider', +});