Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import '../../shared/globals.dart';
import '../../shared/managers/banner_messages.dart';
import '../../shared/primitives/query_parameters.dart';
import '../../shared/title.dart';
import '../../shared/ui/common_widgets.dart';
import 'about_dialog.dart';
import 'app_bar.dart';
import 'bottom_pane.dart';
Expand Down Expand Up @@ -59,7 +60,7 @@ class DevToolsScaffold extends StatefulWidget {
);

/// Returns the list of ScaffoldAction widgets.
static List<Widget> defaultActions({
static List<ScaffoldAction> defaultActions({
Color? color,
Screen? currentScreen,
DevToolsQueryParams? queryParams,
Expand Down
157 changes: 125 additions & 32 deletions packages/devtools_app/lib/src/framework/scaffold/status_line.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@
// 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/ui.dart';
import 'package:material_ui/material_ui.dart';
import 'package:vm_service/vm_service.dart';

import '../../shared/analytics/analytics.dart' as ga;
import '../../shared/analytics/constants.dart' as gac;
import '../../shared/framework/screen.dart';
import '../../shared/globals.dart';
Expand Down Expand Up @@ -57,8 +60,12 @@ class StatusLine extends StatelessWidget {
valueListenable: currentScreen.showIsolateSelector,
builder: (context, showIsolateSelector, _) {
showIsolateSelector = showIsolateSelector && isConnected;
// Keep every footer item on a single line. Items that do not fit are
// truncated rather than overflowing the status line.
return DefaultTextStyle.merge(
style: TextStyle(color: foregroundColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: Container(
decoration: BoxDecoration(
color: backgroundColor,
Expand Down Expand Up @@ -93,6 +100,24 @@ class StatusLine extends StatelessWidget {
final showVideoTutorial =
screenMetaData?.tutorialVideoTimestamp != null ||
screenMetaData?.tutorialVideoUrl != null;
// Secondary items (the tutorial link and, when embedded, the DevTools
// actions) are shown inline only on wide screens. Below [MediaSize.s] they
// move into one overflow menu, so they stay reachable and the device name
// and page status are not squeezed.
final showSecondaryItemsInline = screenWidth > MediaSize.s;
final showTutorialInline = showVideoTutorial && showSecondaryItemsInline;
final embeddedActions = isEmbedded
? DevToolsScaffold.defaultActions(
color: foregroundColor,
currentScreen: currentScreen,
)
: const <ScaffoldAction>[];
final overflowTutorial = showVideoTutorial && !showTutorialInline
? screenMetaData
: null;
final overflowActions = showSecondaryItemsInline
? const <ScaffoldAction>[]
: embeddedActions;
return [
Row(
mainAxisSize: MainAxisSize.min,
Expand All @@ -102,11 +127,10 @@ class StatusLine extends StatelessWidget {
screenWidth: screenWidth,
highlightForConnection: highlightForConnection,
),
if (showVideoTutorial) ...[
if (showTutorialInline) ...[
BulletSpacer(color: foregroundColor),
VideoTutorialLink(
screenMetaData: screenMetaData!,
screenWidth: screenWidth,
highlightForConnection: highlightForConnection,
),
],
Expand All @@ -118,18 +142,28 @@ class StatusLine extends StatelessWidget {
BulletSpacer(color: foregroundColor),
],
if (screenWidth > MediaSize.xs && pageStatus != null) ...[
pageStatus,
// The page status needs to be flexible so that the status line can
// shrink to fit narrow screens without overflowing.
Flexible(child: pageStatus),
BulletSpacer(color: foregroundColor),
],
buildConnectionStatus(context, screenWidth),
if (widerThanXxs && isEmbedded) ...[
// The connection status needs to be flexible so that the status line
// can shrink to fit narrow screens without overflowing.
Flexible(child: buildConnectionStatus(context, screenWidth)),
if (showSecondaryItemsInline && embeddedActions.isNotEmpty) ...[
BulletSpacer(color: foregroundColor),
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: DevToolsScaffold.defaultActions(
color: foregroundColor,
currentScreen: currentScreen,
),
children: embeddedActions,
),
],
if (overflowTutorial != null || overflowActions.isNotEmpty) ...[
BulletSpacer(color: foregroundColor),
_StatusLineOverflowMenu(
tutorial: overflowTutorial,
actions: overflowActions,
color: foregroundColor,
),
],
];
Expand Down Expand Up @@ -174,16 +208,18 @@ class StatusLine extends StatelessWidget {
},
),
const SizedBox(width: denseSpacing),
DevToolsTooltip(
message: 'Connected device',
child: Text(
description,
style: highlightForConnection
? theme.regularTextStyle.copyWith(
color: theme.colorScheme.onPrimary,
)
: theme.regularTextStyle,
overflow: TextOverflow.clip,
Flexible(
child: DevToolsTooltip(
message: 'Connected device',
child: Text(
description,
style: highlightForConnection
? theme.regularTextStyle.copyWith(
color: theme.colorScheme.onPrimary,
)
: theme.regularTextStyle,
overflow: TextOverflow.ellipsis,
),
),
),
],
Expand All @@ -192,7 +228,7 @@ class StatusLine extends StatelessWidget {
return child!;
}
},
child: screenWidth <= MediaSize.xxs
child: screenWidth <= MediaSize.xs
? const DevToolsTooltip(
message: noConnectionMsg,
child: Icon(Icons.warning_amber_rounded, size: actionsIconSize),
Expand Down Expand Up @@ -245,7 +281,6 @@ class VideoTutorialLink extends StatelessWidget {
VideoTutorialLink({
super.key,
required this.screenMetaData,
required this.screenWidth,
required this.highlightForConnection,
}) : assert(
screenMetaData.tutorialVideoTimestamp != null ||
Expand All @@ -254,33 +289,91 @@ class VideoTutorialLink extends StatelessWidget {

final ScreenMetaData screenMetaData;

final MediaSize screenWidth;

final bool highlightForConnection;

static const _devToolsYouTubeVideoUrl = 'https://youtu.be/_EYk-E29edo';

static GaLink _linkFor(ScreenMetaData screenMetaData) {
return GaLink(
display: 'Watch tutorial',
url:
screenMetaData.tutorialVideoUrl ??
'$_devToolsYouTubeVideoUrl${screenMetaData.tutorialVideoTimestamp}',
gaScreenName: screenMetaData.id,
gaSelectedItemDescription:
'${gac.videoTutorialLink}-${screenMetaData.id}',
);
}

/// Opens the tutorial video for [screenMetaData] and records the same
/// analytics event as this link, for use from the overflow menu.
static void open(ScreenMetaData screenMetaData) {
final link = _linkFor(screenMetaData);
unawaited(launchUrlWithErrorHandling(link.url));
ga.select(link.gaScreenName!, link.gaSelectedItemDescription!);
}

@override
Widget build(BuildContext context) {
final color = highlightForConnection
? Theme.of(context).colorScheme.onPrimary
: null;
return LinkIconLabel(
icon: Icons.ondemand_video_rounded,
link: GaLink(
display: screenWidth <= MediaSize.xs ? 'Tutorial' : 'Watch tutorial',
url:
screenMetaData.tutorialVideoUrl ??
'$_devToolsYouTubeVideoUrl${screenMetaData.tutorialVideoTimestamp}',
gaScreenName: screenMetaData.id,
gaSelectedItemDescription:
'${gac.videoTutorialLink}-${screenMetaData.id}',
),
link: _linkFor(screenMetaData),
color: color,
);
}
}

/// The footer items that do not fit at the current width: the video tutorial
/// link and, for embedded DevTools, the DevTools actions (Settings, Feedback,
/// About, and extension settings), in one menu.
///
/// Each item runs the same behavior as its inline counterpart.
class _StatusLineOverflowMenu extends StatelessWidget {
const _StatusLineOverflowMenu({
required this.tutorial,
required this.actions,
required this.color,
});

/// The metadata of the screen whose tutorial should be offered, if any.
final ScreenMetaData? tutorial;

final List<ScaffoldAction> actions;

final Color? color;

@override
Widget build(BuildContext context) {
final tutorial = this.tutorial;
return PopupMenuButton<VoidCallback>(
tooltip: 'More actions',
padding: EdgeInsets.zero,
onSelected: (onSelected) => onSelected(),
itemBuilder: (_) => [
if (tutorial != null)
PopupMenuItem(
value: () => VideoTutorialLink.open(tutorial),
child: const Text('Watch tutorial'),
),
for (final action in actions)
PopupMenuItem(
value: () => action.onPressed(context),
child: Text(action.tooltip),
),
],
child: SizedBox(
width: actionWidgetSize,
child: Center(
child: Icon(Icons.more_horiz, size: actionsIconSize, color: color),
),
),
);
}
}

class IsolateSelector extends StatelessWidget {
const IsolateSelector({super.key, required this.foregroundColor});

Expand Down
74 changes: 55 additions & 19 deletions packages/devtools_app/lib/src/screens/network/network_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,13 @@ class NetworkScreen extends Screen {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Showing ${nf.format(filteredCount)} of '
'${nf.format(totalCount)} '
'${pluralize('request', totalCount)}',
Flexible(
child: Text(
'Showing ${nf.format(filteredCount)} of '
'${nf.format(totalCount)} '
'${pluralize('request', totalCount)}',
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: denseSpacing),
child!,
Expand Down Expand Up @@ -183,6 +186,13 @@ class _NetworkProfilerControlsState extends State<_NetworkProfilerControls>

bool _recording = false;

// The search and filter fields move between rows as the screen width
// crosses the responsive breakpoints below. The stable keys keep each
// field's state (text, selection, and focus) attached to the field when it
// is reparented to a different row in the same frame.
final _searchFieldKey = GlobalKey(debugLabel: 'networkSearchField');
final _filterFieldKey = GlobalKey(debugLabel: 'networkFilterField');

@override
void initState() {
super.initState();
Expand All @@ -202,6 +212,26 @@ class _NetworkProfilerControlsState extends State<_NetworkProfilerControls>
}

final screenWidth = ScreenSize(context).width;
// The search field's navigation controls and the filter field's prefix and
// suffix controls need more width than is left beside the other controls
// on narrow screens. Below [MediaSize.s] the filter field moves to its own
// row, and below [MediaSize.xs] the search field does too. The threshold
// for the filter row is [MediaSize.s] because the filter field's suffix
// controls overflowed by 16 pixels at a 600 pixel width.
final searchOnOwnRow = screenWidth <= MediaSize.xs;
final filterOnOwnRow = screenWidth <= MediaSize.s;
final searchField = SearchField<NetworkController>(
key: _searchFieldKey,
searchController: controller,
searchFieldWidth: screenWidth <= MediaSize.xs
? defaultSearchFieldWidth
: wideSearchFieldWidth,
);
final filterField = StandaloneFilterField<NetworkRequest>(
key: _filterFieldKey,
controller: controller,
filteredItem: 'request',
);
Comment on lines +223 to +234

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[CONCERN] When the screen width crosses the responsive thresholds (MediaSize.xs or MediaSize.s), the searchField and filterField are reparented to different rows in the widget tree. In Flutter, moving a widget to a different position in the tree causes it to lose its state (including focus and cursor position) unless a GlobalKey is used.\n\nTo preserve focus and text selection during resizing, please define two GlobalKeys in your _NetworkProfilerControlsState class and pass them to the fields:\n\ndart\n// In _NetworkProfilerControlsState:\nfinal _searchFieldKey = GlobalKey();\nfinal _filterFieldKey = GlobalKey();\n

Suggested change
final searchField = SearchField<NetworkController>(
searchController: controller,
searchFieldWidth: screenWidth <= MediaSize.xs
? defaultSearchFieldWidth
: wideSearchFieldWidth,
);
final filterField = StandaloneFilterField<NetworkRequest>(
controller: controller,
filteredItem: 'request',
);
final searchField = SearchField<NetworkController>(\n key: _searchFieldKey,\n searchController: controller,\n searchFieldWidth: screenWidth <= MediaSize.xs\n ? defaultSearchFieldWidth\n : wideSearchFieldWidth,\n );\n final filterField = StandaloneFilterField<NetworkRequest>(\n key: _filterFieldKey,\n controller: controller,\n filteredItem: 'request',\n );

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — the fields now have stable GlobalKeys held by _NetworkProfilerControlsState. Verified in a widget test that resizes across the 600 and 900 breakpoints in both directions: without keys, each reparent recreated the filter field's State and internal TextEditingController (selection and focus lost, text re-seeded from the normalized query), and the remounted search field's autofocus stole focus. With keys, text, selection, focus, and State identity survive every crossing.

return Column(
children: [
Row(
Expand All @@ -226,21 +256,14 @@ class _NetworkProfilerControlsState extends State<_NetworkProfilerControls>
),
const SizedBox(width: denseSpacing),
// TODO(kenz): fix focus issue when state is refreshed
Expanded(
child: SearchField<NetworkController>(
searchController: controller,
searchFieldWidth: screenWidth <= MediaSize.xs
? defaultSearchFieldWidth
: wideSearchFieldWidth,
),
),
const SizedBox(width: denseSpacing),
Expanded(
child: StandaloneFilterField<NetworkRequest>(
controller: controller,
filteredItem: 'request',
),
),
if (searchOnOwnRow)
const Spacer()
else
Expanded(child: searchField),
if (!filterOnOwnRow) ...[
const SizedBox(width: denseSpacing),
Expanded(child: filterField),
],
const SizedBox(width: denseSpacing),
OpenSaveButtonGroup(
screenId: ScreenMetaData.network.id,
Expand All @@ -262,6 +285,19 @@ class _NetworkProfilerControlsState extends State<_NetworkProfilerControls>
),
],
),
// On narrow screens, place the search and filter fields on their own
// rows so that each has enough space for its navigation, prefix, and
// suffix controls without overflowing.
if (searchOnOwnRow)
Padding(
padding: const EdgeInsets.only(top: denseSpacing),
child: Row(children: [Expanded(child: searchField)]),
),
if (filterOnOwnRow)
Padding(
padding: const EdgeInsets.only(top: denseSpacing),
child: filterField,
),
if (!_recording)
const Padding(
padding: EdgeInsets.only(top: intermediateSpacing),
Expand Down
6 changes: 5 additions & 1 deletion packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ TODO: Remove this section if there are not any updates.

## Network profiler updates

TODO: Remove this section if there are not any updates.
* Fixed an issue where the Network screen's toolbar controls and footer status
bar would overflow and overlap on narrow screens. On compact widths, the
video tutorial link and the embedded DevTools actions moved into the
More actions menu in the footer.
[#10004](https://github.com/flutter/devtools/issues/10004)

## Logging updates

Expand Down
Loading
Loading