Show Performance and CPU Profiler screens without app connection (#6567) * Add Performance and CPU Profiler screens to list of statically available screens * add OpenSaveButtonGroup * release notes and changelog * dart doc * Update packages/devtools_app/lib/src/screens/performance/performance_screen.dart Co-authored-by: Daniel Chevalier <danchevalier@google.com> --------- Co-authored-by: Daniel Chevalier <danchevalier@google.com>
diff --git a/packages/devtools_app/lib/src/app.dart b/packages/devtools_app/lib/src/app.dart index 2905854..2160835 100644 --- a/packages/devtools_app/lib/src/app.dart +++ b/packages/devtools_app/lib/src/app.dart
@@ -311,7 +311,7 @@ // hot restart service is available for the connected app. const HotRestartButton(), ], - ...DevToolsScaffold.defaultActions(isEmbedded: embed), + ...DevToolsScaffold.defaultActions(), ], ), );
diff --git a/packages/devtools_app/lib/src/framework/scaffold.dart b/packages/devtools_app/lib/src/framework/scaffold.dart index 4249e0e..8c794c1 100644 --- a/packages/devtools_app/lib/src/framework/scaffold.dart +++ b/packages/devtools_app/lib/src/framework/scaffold.dart
@@ -41,7 +41,7 @@ this.page, List<Widget>? actions, this.embed = false, - }) : actions = actions ?? defaultActions(isEmbedded: embed), + }) : actions = actions ?? defaultActions(), super(key: key); DevToolsScaffold.withChild({ @@ -56,16 +56,11 @@ embed: embed, ); - static List<Widget> defaultActions({ - required bool isEmbedded, - Color? color, - }) => - [ + static List<Widget> defaultActions({Color? color}) => [ OpenSettingsAction(color: color), if (FeatureFlags.devToolsExtensions) ExtensionSettingsAction(color: color), ReportFeedbackButton(color: color), - if (!isEmbedded) ImportToolbarAction(color: color), OpenAboutAction(color: color), ];
diff --git a/packages/devtools_app/lib/src/framework/status_line.dart b/packages/devtools_app/lib/src/framework/status_line.dart index 37235c7..5e31f37 100644 --- a/packages/devtools_app/lib/src/framework/status_line.dart +++ b/packages/devtools_app/lib/src/framework/status_line.dart
@@ -113,10 +113,7 @@ BulletSpacer(color: color), Row( crossAxisAlignment: CrossAxisAlignment.end, - children: DevToolsScaffold.defaultActions( - color: color, - isEmbedded: isEmbedded, - ), + children: DevToolsScaffold.defaultActions(color: color), ), ], ];
diff --git a/packages/devtools_app/lib/src/screens/performance/panes/controls/performance_controls.dart b/packages/devtools_app/lib/src/screens/performance/panes/controls/performance_controls.dart index cbe34d1..2ecc60a 100644 --- a/packages/devtools_app/lib/src/screens/performance/panes/controls/performance_controls.dart +++ b/packages/devtools_app/lib/src/screens/performance/panes/controls/performance_controls.dart
@@ -12,7 +12,9 @@ import '../../../../shared/analytics/analytics.dart' as ga; import '../../../../shared/analytics/constants.dart' as gac; import '../../../../shared/common_widgets.dart'; +import '../../../../shared/file_import.dart'; import '../../../../shared/globals.dart'; +import '../../../../shared/screen.dart'; import '../../panes/timeline_events/timeline_events_controller.dart'; import '../../performance_controller.dart'; import 'enhance_tracing/enhance_tracing.dart'; @@ -146,12 +148,9 @@ const MoreDebuggingOptionsButton(), ], const SizedBox(width: denseSpacing), - GaDevToolsButton.iconOnly( - icon: Icons.file_download, - gaScreen: gac.performance, - gaSelection: gac.export, - tooltip: 'Export data', - onPressed: _exportPerformanceData, + OpenSaveButtonGroup( + screenId: ScreenMetaData.performance.id, + onSave: controller.exportData, ), const SizedBox(width: denseSpacing), SettingsOutlinedButton( @@ -163,13 +162,6 @@ ); } - void _exportPerformanceData() { - ga.select(gac.performance, gac.export); - controller.exportData(); - // TODO(kenz): investigate if we need to do any error handling here. Is the - // download always successful? - } - void _openSettingsDialog(BuildContext context) { unawaited( showDialog(
diff --git a/packages/devtools_app/lib/src/screens/performance/performance_screen.dart b/packages/devtools_app/lib/src/screens/performance/performance_screen.dart index d08cf83..eed8a83 100644 --- a/packages/devtools_app/lib/src/screens/performance/performance_screen.dart +++ b/packages/devtools_app/lib/src/screens/performance/performance_screen.dart
@@ -9,11 +9,15 @@ import 'package:devtools_shared/devtools_shared.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../shared/analytics/analytics.dart' as ga; +import '../../shared/analytics/constants.dart' as gac; import '../../shared/banner_messages.dart'; import '../../shared/common_widgets.dart'; +import '../../shared/config_specific/import_export/import_export.dart'; +import '../../shared/file_import.dart'; import '../../shared/globals.dart'; import '../../shared/screen.dart'; import '../../shared/utils.dart'; @@ -35,6 +39,12 @@ @override Widget build(BuildContext context) { + final connected = serviceConnection.serviceManager.hasConnection && + serviceConnection.serviceManager.connectedAppInitialized; + if (!connected && !offlineController.offlineMode.value) { + return const DisconnectedPerformanceScreenBody(); + } + if (serviceConnection.serviceManager.connectedApp?.isDartWebAppNow ?? false) { return const WebPerformanceScreenBody(); @@ -148,6 +158,28 @@ } } +class DisconnectedPerformanceScreenBody extends StatelessWidget { + const DisconnectedPerformanceScreenBody({super.key}); + + static const importInstructions = + 'Open a performance data file that was previously saved from DevTools.'; + + @override + Widget build(BuildContext context) { + return FileImportContainer( + instructions: importInstructions, + actionText: 'Load data', + gaScreen: gac.appSize, + gaSelectionImport: gac.PerformanceEvents.openDataFile.name, + gaSelectionAction: gac.PerformanceEvents.loadDataFromFile.name, + onAction: (jsonFile) { + Provider.of<ImportController>(context, listen: false) + .importData(jsonFile, expectedScreenId: PerformanceScreen.id); + }, + ); + } +} + const timelineLink = 'https://api.flutter.dev/flutter/dart-developer/Timeline-class.html'; const timelineTaskLink =
diff --git a/packages/devtools_app/lib/src/screens/profiler/panes/controls/profiler_screen_controls.dart b/packages/devtools_app/lib/src/screens/profiler/panes/controls/profiler_screen_controls.dart index ead2e93..4b3a006 100644 --- a/packages/devtools_app/lib/src/screens/profiler/panes/controls/profiler_screen_controls.dart +++ b/packages/devtools_app/lib/src/screens/profiler/panes/controls/profiler_screen_controls.dart
@@ -7,7 +7,9 @@ import '../../../../shared/analytics/constants.dart' as gac; import '../../../../shared/common_widgets.dart'; +import '../../../../shared/file_import.dart'; import '../../../../shared/globals.dart'; +import '../../../../shared/screen.dart'; import '../../../../shared/ui/vm_flag_widgets.dart'; import '../../profiler_screen_controller.dart'; @@ -107,8 +109,6 @@ required this.profilerBusy, }); - static const _secondaryControlsMinScreenWidthForText = 1170.0; - static const _profilingControlsMinScreenWidthForText = 875.0; final ProfilerScreenController controller; @@ -155,15 +155,13 @@ controller.cpuProfilerController.profilePeriodFlag!, ), const SizedBox(width: denseSpacing), - ExportButton( - gaScreen: gac.cpuProfiler, - onPressed: !profilerBusy && + OpenSaveButtonGroup( + screenId: ScreenMetaData.cpuProfiler.id, + onSave: !profilerBusy && controller.cpuProfileData != null && controller.cpuProfileData?.isEmpty == false ? _exportPerformance : null, - minScreenWidthForTextBeforeScaling: - _secondaryControlsMinScreenWidthForText, ), ], );
diff --git a/packages/devtools_app/lib/src/screens/profiler/profiler_screen.dart b/packages/devtools_app/lib/src/screens/profiler/profiler_screen.dart index efc147b..5146b33 100644 --- a/packages/devtools_app/lib/src/screens/profiler/profiler_screen.dart +++ b/packages/devtools_app/lib/src/screens/profiler/profiler_screen.dart
@@ -6,11 +6,15 @@ import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:vm_service/vm_service.dart' hide Stack; import '../../shared/analytics/analytics.dart' as ga; +import '../../shared/analytics/constants.dart' as gac; import '../../shared/banner_messages.dart'; import '../../shared/common_widgets.dart'; +import '../../shared/config_specific/import_export/import_export.dart'; +import '../../shared/file_import.dart'; import '../../shared/globals.dart'; import '../../shared/primitives/listenable.dart'; import '../../shared/screen.dart'; @@ -35,7 +39,15 @@ const FixedValueListenable<bool>(true); @override - Widget build(BuildContext context) => const ProfilerScreenBody(); + Widget build(BuildContext context) { + final connected = serviceConnection.serviceManager.hasConnection && + serviceConnection.serviceManager.connectedAppInitialized; + if (!connected && !offlineController.offlineMode.value) { + return const DisconnectedCpuProfilerScreenBody(); + } + + return const ProfilerScreenBody(); + } } class ProfilerScreenBody extends StatefulWidget { @@ -164,3 +176,25 @@ ); } } + +class DisconnectedCpuProfilerScreenBody extends StatelessWidget { + const DisconnectedCpuProfilerScreenBody({super.key}); + + static const importInstructions = + 'Open a CPU profile that was previously saved from DevTools'; + + @override + Widget build(BuildContext context) { + return FileImportContainer( + instructions: importInstructions, + actionText: 'Load data', + gaScreen: gac.appSize, + gaSelectionImport: gac.CpuProfilerEvents.openDataFile.name, + gaSelectionAction: gac.CpuProfilerEvents.loadDataFromFile.name, + onAction: (jsonFile) { + Provider.of<ImportController>(context, listen: false) + .importData(jsonFile, expectedScreenId: ProfilerScreen.id); + }, + ); + } +}
diff --git a/packages/devtools_app/lib/src/shared/analytics/constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants.dart index c51c3d5..4546930 100644 --- a/packages/devtools_app/lib/src/shared/analytics/constants.dart +++ b/packages/devtools_app/lib/src/shared/analytics/constants.dart
@@ -129,7 +129,8 @@ const clear = 'clear'; const record = 'record'; const stop = 'stop'; -const export = 'export'; +const openFile = 'openFile'; +const saveFile = 'saveFile'; const expandAll = 'expandAll'; const collapseAll = 'collapseAll'; const profileModeDocs = 'profileModeDocs';
diff --git a/packages/devtools_app/lib/src/shared/analytics/constants/_cpu_profiler_constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants/_cpu_profiler_constants.dart index 4784183..01ffbff 100644 --- a/packages/devtools_app/lib/src/shared/analytics/constants/_cpu_profiler_constants.dart +++ b/packages/devtools_app/lib/src/shared/analytics/constants/_cpu_profiler_constants.dart
@@ -11,6 +11,8 @@ cpuProfileFlameChartHelp, cpuProfileProcessingTime, cpuProfileDisplayTreeGuidelines, + openDataFile, + loadDataFromFile; } enum CpuProfilerDocs {
diff --git a/packages/devtools_app/lib/src/shared/analytics/constants/_performance_constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants/_performance_constants.dart index 5336fee..ceaf8d9 100644 --- a/packages/devtools_app/lib/src/shared/analytics/constants/_performance_constants.dart +++ b/packages/devtools_app/lib/src/shared/analytics/constants/_performance_constants.dart
@@ -29,7 +29,9 @@ perfettoScrollToTimeRange, perfettoShowHelp, performanceSettings, - traceCategories; + traceCategories, + openDataFile, + loadDataFromFile; const PerformanceEvents([this.nameOverride]);
diff --git a/packages/devtools_app/lib/src/shared/common_widgets.dart b/packages/devtools_app/lib/src/shared/common_widgets.dart index 67eb428..c16bfd4 100644 --- a/packages/devtools_app/lib/src/shared/common_widgets.dart +++ b/packages/devtools_app/lib/src/shared/common_widgets.dart
@@ -704,25 +704,6 @@ Size get preferredSize => Size.zero; } -/// Button to export data. -/// -/// * `minScreenWidthForTextBeforeScaling`: The minimum width the button can be before the text is -/// omitted. -/// * `onPressed`: The callback to be called upon pressing the button. -class ExportButton extends GaDevToolsButton { - ExportButton({ - required super.gaScreen, - super.key, - super.onPressed, - super.minScreenWidthForTextBeforeScaling, - super.tooltip = 'Export data', - }) : super( - icon: Icons.file_download, - label: 'Export', - gaSelection: gac.export, - ); -} - /// Button to open related information / documentation. /// /// [tooltip] specifies the hover text for the button.
diff --git a/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart b/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart index d8f8f0d..34e5cdf 100644 --- a/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart +++ b/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart
@@ -2,19 +2,12 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:async'; import 'dart:convert'; import 'package:devtools_app_shared/service.dart'; -import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'package:provider/provider.dart'; import '../../../../devtools.dart'; -import '../../analytics/analytics.dart' as ga; -import '../../analytics/constants.dart' as gac; -import '../../common_widgets.dart'; -import '../../file_import.dart'; import '../../globals.dart'; import '../../primitives/simple_items.dart'; import '../../primitives/utils.dart'; @@ -42,7 +35,6 @@ activeScreenId, } -// TODO(kenz): we should support a file picker import for desktop. class ImportController { ImportController( this._pushSnapshotScreenForImport, @@ -55,7 +47,7 @@ DateTime? previousImportTime; // TODO(kenz): improve error handling here or in snapshot_screen.dart. - void importData(DevToolsJsonFile jsonFile) { + void importData(DevToolsJsonFile jsonFile, {String? expectedScreenId}) { final json = jsonFile.data; // Do not allow two different imports within 500 ms of each other. This is a @@ -81,6 +73,14 @@ // TODO(kenz): support imports for more than one screen at a time. final activeScreenId = devToolsSnapshot[DevToolsExportKeys.activeScreenId.name]; + if (expectedScreenId != null && activeScreenId != expectedScreenId) { + notificationService.push( + 'Expected a data file for screen \'$expectedScreenId\' but received one' + ' for screen \'$activeScreenId\'. Please open a file for screen \'$expectedScreenId\'.', + ); + return; + } + final connectedApp = (devToolsSnapshot[DevToolsExportKeys.connectedApp.name] ?? <String, Object>{}) @@ -185,29 +185,3 @@ return jsonEncode(data); } } - -class ImportToolbarAction extends ScaffoldAction { - ImportToolbarAction({super.key, Color? color}) - : super( - icon: Icons.upload_rounded, - tooltip: 'Load data for viewing in DevTools.', - color: color, - onPressed: (context) => unawaited(_importFile(context)), - ); - - static Future<void> _importFile(BuildContext context) async { - ga.select( - gac.devToolsMain, - gac.importFile, - ); - final DevToolsJsonFile? importedFile = await importFileFromPicker( - acceptedTypes: ['json'], - ); - - if (importedFile != null) { - // ignore: use_build_context_synchronously, by design - Provider.of<ImportController>(context, listen: false) - .importData(importedFile); - } - } -}
diff --git a/packages/devtools_app/lib/src/shared/file_import.dart b/packages/devtools_app/lib/src/shared/file_import.dart index 2ae18dd..88dadf8 100644 --- a/packages/devtools_app/lib/src/shared/file_import.dart +++ b/packages/devtools_app/lib/src/shared/file_import.dart
@@ -7,18 +7,70 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'analytics/analytics.dart' as ga; +import 'analytics/constants.dart' as gac; import 'common_widgets.dart'; import 'config_specific/drag_and_drop/drag_and_drop.dart'; +import 'config_specific/import_export/import_export.dart'; import 'globals.dart'; import 'primitives/utils.dart'; +class OpenSaveButtonGroup extends StatelessWidget { + const OpenSaveButtonGroup({ + super.key, + required this.screenId, + required this.onSave, + }); + + final String screenId; + + final VoidCallback? onSave; + + @override + Widget build(BuildContext context) { + return RoundedButtonGroup( + items: [ + ButtonGroupItemData( + icon: Icons.file_upload, + tooltip: 'Open a file that was previously saved from DevTools', + onPressed: () async { + ga.select(screenId, gac.openFile); + final importedFile = + await importFileFromPicker(acceptedTypes: const ['json']); + if (importedFile != null) { + // ignore: use_build_context_synchronously, intentional use. + Provider.of<ImportController>(context, listen: false) + .importData(importedFile, expectedScreenId: screenId); + } else { + notificationService.push( + 'Something went wrong. Could not open selected file.', + ); + } + }, + ), + ButtonGroupItemData( + icon: Icons.file_download, + tooltip: 'Save this screen\'s data for offline viewing', + onPressed: onSave != null + ? () { + ga.select(screenId, gac.saveFile); + onSave!.call(); + } + : null, + ), + ], + ); + } +} + class FileImportContainer extends StatefulWidget { const FileImportContainer({ - required this.title, required this.instructions, required this.gaScreen, required this.gaSelectionImport, + this.title, this.gaSelectionAction, this.actionText, this.onAction, @@ -28,7 +80,7 @@ super.key, }); - final String title; + final String? title; final String instructions; @@ -59,13 +111,16 @@ @override Widget build(BuildContext context) { + final title = widget.title; return Column( children: [ - Text( - widget.title, - style: TextStyle(fontSize: scaleByFontFactor(18.0)), - ), - const SizedBox(height: defaultSpacing), + if (title != null) ...[ + Text( + title, + style: TextStyle(fontSize: scaleByFontFactor(18.0)), + ), + const SizedBox(height: defaultSpacing), + ], Expanded( // TODO(kenz): improve drag over highlight. child: DragAndDrop( @@ -165,7 +220,6 @@ gaScreen: widget.gaScreen, gaSelection: widget.gaSelectionAction!, label: widget.actionText!, - icon: Icons.highlight, elevated: true, onPressed: importedFile != null ? () => widget.onAction!(importedFile!) @@ -248,7 +302,7 @@ return GaDevToolsButton( onPressed: onPressed, icon: Icons.file_upload, - label: 'Import File', + label: 'Open file', gaScreen: gaScreen, gaSelection: gaSelection, elevated: elevatedButton,
diff --git a/packages/devtools_app/lib/src/shared/screen.dart b/packages/devtools_app/lib/src/shared/screen.dart index 3e662c8..fdc3922 100644 --- a/packages/devtools_app/lib/src/shared/screen.dart +++ b/packages/devtools_app/lib/src/shared/screen.dart
@@ -37,6 +37,7 @@ title: 'Performance', icon: Octicons.pulse, worksOffline: true, + requiresConnection: false, tutorialVideoTimestamp: '?t=261', ), cpuProfiler( @@ -45,6 +46,7 @@ icon: Octicons.dashboard, requiresDartVm: true, worksOffline: true, + requiresConnection: false, tutorialVideoTimestamp: '?t=340', ), memory(
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index 01f51fc..9d08ee5 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -32,9 +32,18 @@  +* Made the Performance screen available when there is no connected app. Performance data that was +previously saved from DevTools can be reloaded for viewing from this screen. [#6567](https://github.com/flutter/devtools/pull/6567) +* Added an "Open" button to the Performance controls for loading data that was previously saved +from DevTools. [#6567](https://github.com/flutter/devtools/pull/6567) + ## CPU profiler updates * Tree guidelines are now always enabled for the "Bottom Up" and "Call Tree" tabs. [#6534](https://github.com/flutter/devtools/pull/6534) +* Made the CPU profiler screen available when there is no connected app. CPU profiles that were +previously saved from DevTools can be reloaded for viewing from this screen. [#6567](https://github.com/flutter/devtools/pull/6567) +* Added an "Open" button to the CPU profiler controls for loading data that was previously saved +from DevTools. [#6567](https://github.com/flutter/devtools/pull/6567) ## Memory updates
diff --git a/packages/devtools_app/test/cpu_profiler/profiler_screen_test.dart b/packages/devtools_app/test/cpu_profiler/profiler_screen_test.dart index 120df12..adc6e25 100644 --- a/packages/devtools_app/test/cpu_profiler/profiler_screen_test.dart +++ b/packages/devtools_app/test/cpu_profiler/profiler_screen_test.dart
@@ -7,6 +7,7 @@ import 'package:devtools_app/src/screens/profiler/panes/controls/cpu_profiler_controls.dart'; import 'package:devtools_app/src/screens/profiler/profiler_status.dart'; import 'package:devtools_app/src/service/vm_flags.dart' as vm_flags; +import 'package:devtools_app/src/shared/file_import.dart'; import 'package:devtools_app/src/shared/ui/vm_flag_widgets.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:flutter/material.dart'; @@ -38,6 +39,7 @@ expect(find.text('Profile app start up'), findsOneWidget); } expect(find.byType(CpuSamplingRateDropdown), findsOneWidget); + expect(find.byType(OpenSaveButtonGroup), findsOneWidget); expect( find.byType(ProfileRecordingInstructions), findsOneWidget, @@ -144,6 +146,7 @@ expect(find.byType(StopRecordingButton), findsNothing); expect(find.byType(ClearButton), findsNothing); expect(find.byType(CpuSamplingRateDropdown), findsNothing); + expect(find.byType(OpenSaveButtonGroup), findsNothing); await tester.runAsync(() async { await tester.tap(find.text('Enable profiler'));
diff --git a/packages/devtools_app/test/performance/controls/performance_controls_test.dart b/packages/devtools_app/test/performance/controls/performance_controls_test.dart index f7fa2b5..ff7b261 100644 --- a/packages/devtools_app/test/performance/controls/performance_controls_test.dart +++ b/packages/devtools_app/test/performance/controls/performance_controls_test.dart
@@ -4,6 +4,7 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/src/screens/performance/panes/controls/performance_controls.dart'; +import 'package:devtools_app/src/shared/file_import.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; @@ -76,7 +77,7 @@ expect(find.text('Performance Overlay'), findsOneWidget); expect(find.text('Enhance Tracing'), findsOneWidget); expect(find.text('More debugging options'), findsOneWidget); - expect(find.byIcon(Icons.file_download), findsOneWidget); + expect(find.byType(OpenSaveButtonGroup), findsOneWidget); expect(find.byIcon(Icons.settings_outlined), findsOneWidget); }, ); @@ -99,7 +100,7 @@ expect(find.text('Performance Overlay'), findsNothing); expect(find.text('Enhance Tracing'), findsNothing); expect(find.text('More debugging options'), findsNothing); - expect(find.byIcon(Icons.file_download), findsOneWidget); + expect(find.byType(OpenSaveButtonGroup), findsOneWidget); expect(find.byIcon(Icons.settings_outlined), findsOneWidget); }, ); @@ -118,7 +119,7 @@ expect(find.text('Performance Overlay'), findsNothing); expect(find.text('Enhance Tracing'), findsNothing); expect(find.text('More debugging options'), findsNothing); - expect(find.byIcon(Icons.file_download), findsNothing); + expect(find.byType(OpenSaveButtonGroup), findsNothing); expect(find.byIcon(Icons.settings_outlined), findsNothing); offlineController.exitOfflineMode(); },
diff --git a/packages/devtools_app_shared/CHANGELOG.md b/packages/devtools_app_shared/CHANGELOG.md index bb98535..476d802 100644 --- a/packages/devtools_app_shared/CHANGELOG.md +++ b/packages/devtools_app_shared/CHANGELOG.md
@@ -1,3 +1,6 @@ +## 0.0.7 +* Add `RoundedButtonGroup` common widget. + ## 0.0.6 * Add `profilePlatformChannels` to known service extensions. * Fix a bug where service extension states were not getting cleared on app disconnect.
diff --git a/packages/devtools_app_shared/lib/src/ui/common.dart b/packages/devtools_app_shared/lib/src/ui/common.dart index 83e45f2..adb1b97 100644 --- a/packages/devtools_app_shared/lib/src/ui/common.dart +++ b/packages/devtools_app_shared/lib/src/ui/common.dart
@@ -566,6 +566,117 @@ } } +/// A group of buttons that share a common border. +/// +/// This widget ensures the buttons are displayed with proper borders on the +/// interior and exterior of the group. The attirbutes for each button can be +/// defined by [ButtonGroupItemData] and included in [items]. +final class RoundedButtonGroup extends StatelessWidget { + const RoundedButtonGroup({ + super.key, + required this.items, + this.minScreenWidthForTextBeforeScaling, + }); + + final List<ButtonGroupItemData> items; + final double? minScreenWidthForTextBeforeScaling; + + @override + Widget build(BuildContext context) { + Widget buildButton(int index) { + final itemData = items[index]; + Widget button = _ButtonGroupButton( + buttonData: itemData, + roundedLeftBorder: index == 0, + roundedRightBorder: index == items.length - 1, + minScreenWidthForTextBeforeScaling: minScreenWidthForTextBeforeScaling, + ); + if (index != 0) { + button = Container( + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: Theme.of(context).focusColor, + ), + ), + ), + child: button, + ); + } + return button; + } + + return SizedBox( + height: defaultButtonHeight, + child: RoundedOutlinedBorder( + child: Row( + children: [ + for (int i = 0; i < items.length; i++) buildButton(i), + ], + ), + ), + ); + } +} + +final class _ButtonGroupButton extends StatelessWidget { + const _ButtonGroupButton({ + required this.buttonData, + this.roundedLeftBorder = false, + this.roundedRightBorder = false, + this.minScreenWidthForTextBeforeScaling, + }); + + final ButtonGroupItemData buttonData; + final bool roundedLeftBorder; + final bool roundedRightBorder; + final double? minScreenWidthForTextBeforeScaling; + + @override + Widget build(BuildContext context) { + return DevToolsTooltip( + message: buttonData.tooltip, + child: OutlinedButton( + autofocus: buttonData.autofocus, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: densePadding), + side: BorderSide.none, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.horizontal( + left: roundedLeftBorder ? defaultRadius : Radius.zero, + right: roundedRightBorder ? defaultRadius : Radius.zero, + ), + ), + ), + onPressed: buttonData.onPressed, + child: MaterialIconLabel( + label: buttonData.label, + iconData: buttonData.icon, + minScreenWidthForTextBeforeScaling: + minScreenWidthForTextBeforeScaling, + ), + ), + ); + } +} + +final class ButtonGroupItemData { + const ButtonGroupItemData({ + this.label, + this.icon, + String? tooltip, + this.onPressed, + this.autofocus = false, + }) : tooltip = tooltip ?? label, + assert(label != null || icon != null); + + final String? label; + final IconData? icon; + final String? tooltip; + final VoidCallback? onPressed; + final bool autofocus; +} + final class DevToolsFilterButton extends StatelessWidget { const DevToolsFilterButton({ Key? key,
diff --git a/packages/devtools_app_shared/pubspec.yaml b/packages/devtools_app_shared/pubspec.yaml index 99a86f7..16ad65d 100644 --- a/packages/devtools_app_shared/pubspec.yaml +++ b/packages/devtools_app_shared/pubspec.yaml
@@ -1,6 +1,6 @@ name: devtools_app_shared description: Package of Dart & Flutter structures shared between devtools_app and devtools extensions. -version: 0.0.6 +version: 0.0.7 repository: https://github.com/flutter/devtools/tree/master/packages/devtools_extensions environment: