diff --git a/packages/devtools_app/lib/src/framework/scaffold/scaffold.dart b/packages/devtools_app/lib/src/framework/scaffold/scaffold.dart index 90b3e884ffe..db8a3a6750a 100644 --- a/packages/devtools_app/lib/src/framework/scaffold/scaffold.dart +++ b/packages/devtools_app/lib/src/framework/scaffold/scaffold.dart @@ -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'; @@ -59,7 +60,7 @@ class DevToolsScaffold extends StatefulWidget { ); /// Returns the list of ScaffoldAction widgets. - static List defaultActions({ + static List defaultActions({ Color? color, Screen? currentScreen, DevToolsQueryParams? queryParams, diff --git a/packages/devtools_app/lib/src/framework/scaffold/status_line.dart b/packages/devtools_app/lib/src/framework/scaffold/status_line.dart index a292d6ef68c..4a7238c5a55 100644 --- a/packages/devtools_app/lib/src/framework/scaffold/status_line.dart +++ b/packages/devtools_app/lib/src/framework/scaffold/status_line.dart @@ -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'; @@ -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, @@ -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 []; + final overflowTutorial = showVideoTutorial && !showTutorialInline + ? screenMetaData + : null; + final overflowActions = showSecondaryItemsInline + ? const [] + : embeddedActions; return [ Row( mainAxisSize: MainAxisSize.min, @@ -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, ), ], @@ -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, ), ], ]; @@ -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, + ), ), ), ], @@ -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), @@ -245,7 +281,6 @@ class VideoTutorialLink extends StatelessWidget { VideoTutorialLink({ super.key, required this.screenMetaData, - required this.screenWidth, required this.highlightForConnection, }) : assert( screenMetaData.tutorialVideoTimestamp != null || @@ -254,12 +289,30 @@ 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 @@ -267,20 +320,60 @@ class VideoTutorialLink extends StatelessWidget { : 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 actions; + + final Color? color; + + @override + Widget build(BuildContext context) { + final tutorial = this.tutorial; + return PopupMenuButton( + 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}); diff --git a/packages/devtools_app/lib/src/screens/network/network_screen.dart b/packages/devtools_app/lib/src/screens/network/network_screen.dart index ef42f6476b3..3e6d120c163 100644 --- a/packages/devtools_app/lib/src/screens/network/network_screen.dart +++ b/packages/devtools_app/lib/src/screens/network/network_screen.dart @@ -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!, @@ -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(); @@ -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( + key: _searchFieldKey, + searchController: controller, + searchFieldWidth: screenWidth <= MediaSize.xs + ? defaultSearchFieldWidth + : wideSearchFieldWidth, + ); + final filterField = StandaloneFilterField( + key: _filterFieldKey, + controller: controller, + filteredItem: 'request', + ); return Column( children: [ Row( @@ -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( - searchController: controller, - searchFieldWidth: screenWidth <= MediaSize.xs - ? defaultSearchFieldWidth - : wideSearchFieldWidth, - ), - ), - const SizedBox(width: denseSpacing), - Expanded( - child: StandaloneFilterField( - 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, @@ -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), diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index 7e9c5050340..ae92c0c6588 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md @@ -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 diff --git a/packages/devtools_app/test/framework/scaffold/status_line_test.dart b/packages/devtools_app/test/framework/scaffold/status_line_test.dart new file mode 100644 index 00000000000..50a488d0903 --- /dev/null +++ b/packages/devtools_app/test/framework/scaffold/status_line_test.dart @@ -0,0 +1,511 @@ +// 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/src/framework/scaffold/about_dialog.dart'; +import 'package:devtools_app/src/framework/scaffold/settings_dialog.dart'; +import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:devtools_test/helpers.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; + +import '../../test_infra/responsive_layout_utils.dart'; + +/// The height of the status line. It is fixed, so the overflow menu trigger is +/// clamped to it. The trigger is [actionWidgetSize] wide and this tall. +const _footerHeight = statusLineHeight + 2 * densePadding; + +/// The width, in logical pixels, at and above which the video tutorial link and +/// the embedded DevTools actions are shown inline. Below it they are in the +/// overflow menu. +const _inlineSecondaryItemsMinWidth = 900.0; + +/// The compact width used for menu interaction tests. The Settings dialog's +/// content overflows below this width, so Settings is tested only here; see +/// [_expectDialogOpens]. +const _compactInteractionWidth = 600.0; + +const _noClientConnection = 'No client connection'; + +/// The URL of the video tutorial for [screen], as the footer link opens it. +String _tutorialUrl(ScreenMetaData screen) { + return 'https://youtu.be/_EYk-E29edo${screen.tutorialVideoTimestamp}'; +} + +Finder _inStatusLine(Finder finder) { + return find.descendant(of: find.byType(StatusLine), matching: finder); +} + +/// The footer's connection status. It is text when there is room for it, and +/// otherwise an icon with a tooltip. +Finder _connectionStatus({required bool connected}) { + if (connected) return _inStatusLine(find.byTooltip('Connected device')); + final icon = _inStatusLine(find.byTooltip(_noClientConnection)); + return icon.evaluate().isNotEmpty + ? icon + : _inStatusLine(find.text(_noClientConnection)); +} + +Finder _trigger() => _inStatusLine(find.byTooltip('More actions')); + +/// Gives keyboard focus to the overflow menu trigger by requesting focus on the +/// focus node that the trigger's button owns. It does not press Tab, so Tab +/// traversal to the trigger is not covered by these tests. +Future _focusTrigger(WidgetTester tester) async { + final icon = find.descendant(of: _trigger(), matching: find.byType(Icon)); + final triggerFocus = Focus.of(tester.element(icon)); + triggerFocus.requestFocus(); + await tester.pump(); + expect( + FocusManager.instance.primaryFocus, + triggerFocus, + reason: 'The overflow menu trigger should take keyboard focus.', + ); +} + +/// Opens the overflow menu from the keyboard, as a keyboard user would. +Future _openMenuWithKeyboard(WidgetTester tester) async { + await _focusTrigger(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(const Duration(milliseconds: 300)); + expect( + find.byType(PopupMenuItem), + findsWidgets, + reason: 'Enter on the focused trigger should open the menu.', + ); +} + +/// The label of the menu item that has keyboard focus, if any. +String? _focusedMenuItemLabel() { + final focus = FocusManager.instance.primaryFocus?.context; + final item = focus + ?.findAncestorWidgetOfExactType>(); + final child = item?.child; + return child is Text ? child.data : null; +} + +/// Activates the menu item labeled [label] from the keyboard: arrow keys move +/// focus through the open menu, and Enter activates the focused item. +Future _activateMenuItem(WidgetTester tester, String label) async { + for (var i = 0; i < 10 && _focusedMenuItemLabel() != label; i++) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + } + expect( + _focusedMenuItemLabel(), + label, + reason: '"$label" should be reachable with the arrow keys.', + ); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(const Duration(milliseconds: 500)); +} + +/// Asserts that [dialog] is shown, then closes it with its Close button. +/// +/// The Settings dialog's checkbox rows overflow below 600 pixels (224 pixels at +/// 328 pixels), in shared dialog code that is reported separately from this +/// change. The About dialog has no overflow at 328 pixels. +Future _expectDialogOpens(WidgetTester tester, Finder dialog) async { + expect(dialog, findsOneWidget); + await tester.tap(find.text('CLOSE')); + await tester.pump(const Duration(milliseconds: 500)); + expect(dialog, findsNothing); +} + +void main() { + setUpAll(loadFonts); + + late FakeServiceConnectionManager connection; + late NetworkController network; + late LoggingController logging; + late DebuggerController debugger; + + setUp(() { + connection = createLayoutServiceConnection(); + setUpLayoutGlobals(connection); + network = NetworkController(); + logging = LoggingController(); + debugger = DebuggerController(); + }); + + Future> pumpFooter( + WidgetTester tester, { + required String pageId, + required bool embedded, + required bool connected, + }) { + return collectFlutterErrors( + () => pumpDevToolsScaffold( + tester, + pageId: pageId, + embedded: embedded, + connected: connected, + connection: connection, + network: network, + logging: logging, + debugger: debugger, + ), + ); + } + + group('footer layout', () { + for (final embedded in [false, true]) { + for (final connected in [true, false]) { + final mode = + '${embedded ? 'embedded' : 'standalone'}, ' + '${connected ? 'connected' : 'disconnected'}'; + + for (final width in responsiveLayoutWidths) { + testWidgetsWithWindowSize( + 'Network footer ($mode) has no overlap or clipping at $width', + Size(width, responsiveLayoutHeight), + (WidgetTester tester) async { + final errors = await pumpFooter( + tester, + pageId: ScreenMetaData.network.id, + embedded: embedded, + connected: connected, + ); + expect(errors, isEmpty, reason: errors.join('\n')); + _expectFooterGeometry(tester, width: width, connected: connected); + await stopNetworkPolling(tester, network); + }, + ); + } + } + } + + for (final width in [328.0, 600.0, 901.0, 1400.0]) { + testWidgetsWithWindowSize( + 'Logging footer (embedded) has no overlap or clipping at $width', + Size(width, responsiveLayoutHeight), + (WidgetTester tester) async { + final errors = await pumpFooter( + tester, + pageId: ScreenMetaData.logging.id, + embedded: true, + connected: true, + ); + expect(errors, isEmpty, reason: errors.join('\n')); + _expectFooterGeometry(tester, width: width, connected: true); + }, + ); + } + + // The page status is wrapped in a Flexible, not an Expanded, so that it + // keeps its natural width and the spaceBetween alignment keeps spacing the + // footer items evenly on wide screens, as it did before this change. + for (final embedded in [false, true]) { + for (final width in [900.0, 901.0, 1400.0]) { + testWidgetsWithWindowSize( + 'page status keeps its natural width on a wide screen ' + '(${embedded ? 'embedded' : 'standalone'}, $width)', + Size(width, responsiveLayoutHeight), + (WidgetTester tester) async { + final errors = await pumpFooter( + tester, + pageId: ScreenMetaData.network.id, + embedded: embedded, + connected: true, + ); + expect(errors, isEmpty, reason: errors.join('\n')); + + final statusText = _inStatusLine(find.textContaining(' of ')); + expect(statusText, findsOneWidget); + final textRect = tester.getRect(statusText); + // The nearest ancestor Row is the page status box, which also + // holds the recording spinner. + final boxRect = tester.getRect( + find.ancestor(of: statusText, matching: find.byType(Row)).first, + ); + expect( + boxRect.width, + lessThanOrEqualTo( + textRect.width + denseSpacing + smallProgressSize + 8.0, + ), + reason: + 'The page status should hug its content on a wide screen; ' + 'an Expanded wrapper would stretch it to fill the slack.', + ); + + if (!embedded) { + // With no trailing actions, the connection status is the last + // item and stays pinned to the right edge. + final connection = tester.getRect( + _connectionStatus(connected: true), + ); + final statusLine = tester.getRect(find.byType(StatusLine)); + expect( + statusLine.right - connection.right, + lessThanOrEqualTo(defaultSpacing + geometryTolerance), + ); + } + await stopNetworkPolling(tester, network); + }, + ); + } + } + }); + + group('overflow menu', () { + testWidgetsWithWindowSize( + 'trigger is a footer-height button with an accessible name', + const Size(_compactInteractionWidth, responsiveLayoutHeight), + (WidgetTester tester) async { + final handle = tester.ensureSemantics(); + await pumpFooter( + tester, + pageId: ScreenMetaData.network.id, + embedded: true, + connected: true, + ); + final trigger = _trigger(); + expect(trigger, findsOneWidget); + expect(tester.getSize(trigger).width, actionWidgetSize); + expect( + tester.getSize(trigger).height, + lessThanOrEqualTo(_footerHeight), + ); + expect( + tester.getSemantics(trigger), + matchesSemantics( + tooltip: 'More actions', + isButton: true, + isEnabled: true, + isFocusable: true, + hasEnabledState: true, + hasTapAction: true, + hasFocusAction: true, + hasExpandedState: true, + ), + ); + handle.dispose(); + await stopNetworkPolling(tester, network); + }, + ); + + testWidgetsWithWindowSize( + 'opens with Enter, lists every action, and closes with Escape', + const Size(_compactInteractionWidth, responsiveLayoutHeight), + (WidgetTester tester) async { + await pumpFooter( + tester, + pageId: ScreenMetaData.network.id, + embedded: true, + connected: true, + ); + await _openMenuWithKeyboard(tester); + for (final label in [ + 'Watch tutorial', + 'Settings', + 'Report feedback', + 'About DevTools', + ]) { + expect( + find.widgetWithText(PopupMenuItem, label), + findsOneWidget, + ); + } + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byType(PopupMenuItem), findsNothing); + await stopNetworkPolling(tester, network); + }, + ); + + testWidgetsWithWindowSize( + 'Settings from the menu opens the Settings dialog', + const Size(_compactInteractionWidth, responsiveLayoutHeight), + (WidgetTester tester) async { + await pumpFooter( + tester, + pageId: ScreenMetaData.network.id, + embedded: true, + connected: true, + ); + await _openMenuWithKeyboard(tester); + final errors = await collectFlutterErrors( + () => _activateMenuItem(tester, 'Settings'), + ); + expect(errors, isEmpty, reason: errors.join('\n')); + await _expectDialogOpens(tester, find.byType(SettingsDialog)); + await stopNetworkPolling(tester, network); + }, + ); + + for (final width in [_compactInteractionWidth, 328.0]) { + testWidgetsWithWindowSize( + 'About from the menu opens the About dialog' + ' at $width', + Size(width, responsiveLayoutHeight), + (WidgetTester tester) async { + await pumpFooter( + tester, + pageId: ScreenMetaData.network.id, + embedded: true, + connected: true, + ); + await _openMenuWithKeyboard(tester); + final errors = await collectFlutterErrors( + () => _activateMenuItem(tester, 'About DevTools'), + ); + expect(errors, isEmpty, reason: errors.join('\n')); + await _expectDialogOpens(tester, find.byType(DevToolsAboutDialog)); + await stopNetworkPolling(tester, network); + }, + ); + } + + for (final width in [_compactInteractionWidth, 328.0]) { + testWidgetsWithWindowSize( + 'Watch tutorial from the menu opens the same video and records the same ' + 'analytics event as the inline link' + ' at $width', + Size(width, responsiveLayoutHeight), + (WidgetTester tester) async { + final urls = UrlLaunchRecorder()..install(); + final analytics = AnalyticsRecorder()..install(); + addTearDown(() { + urls.dispose(); + analytics.dispose(); + }); + await pumpFooter( + tester, + pageId: ScreenMetaData.network.id, + embedded: true, + connected: true, + ); + await _openMenuWithKeyboard(tester); + await _activateMenuItem(tester, 'Watch tutorial'); + await tester.pump(const Duration(milliseconds: 200)); + + expect(urls.launched, [_tutorialUrl(ScreenMetaData.network)]); + expect( + analytics.recordedSelection( + screenId: ScreenMetaData.network.id, + item: AnalyticsRecorder.tutorialItem(ScreenMetaData.network.id), + ), + isTrue, + ); + await stopNetworkPolling(tester, network); + }, + ); + } + + for (final width in [_compactInteractionWidth, 328.0]) { + testWidgetsWithWindowSize( + 'Report feedback from the menu opens the issue tracker and records the ' + 'analytics event' + ' at $width', + Size(width, responsiveLayoutHeight), + (WidgetTester tester) async { + final urls = UrlLaunchRecorder()..install(); + final analytics = AnalyticsRecorder()..install(); + addTearDown(() { + urls.dispose(); + analytics.dispose(); + }); + await pumpFooter( + tester, + pageId: ScreenMetaData.network.id, + embedded: true, + connected: true, + ); + await _openMenuWithKeyboard(tester); + await _activateMenuItem(tester, 'Report feedback'); + await tester.pump(const Duration(milliseconds: 200)); + + expect(urls.launched, hasLength(1)); + expect( + urls.launched.single, + startsWith('https://github.com/flutter/devtools/issues/new'), + ); + expect( + analytics.recordedSelection( + screenId: 'main', + item: 'feedbackButton', + ), + isTrue, + ); + await stopNetworkPolling(tester, network); + }, + ); + } + + testWidgetsWithWindowSize( + 'inline tutorial link at a wide width opens the same video', + const Size(1400, responsiveLayoutHeight), + (WidgetTester tester) async { + final urls = UrlLaunchRecorder()..install(); + addTearDown(urls.dispose); + await pumpFooter( + tester, + pageId: ScreenMetaData.network.id, + embedded: true, + connected: true, + ); + expect(_trigger(), findsNothing); + await tester.tap(_inStatusLine(find.text('Watch tutorial'))); + await tester.pump(const Duration(milliseconds: 200)); + expect(urls.launched, [_tutorialUrl(ScreenMetaData.network)]); + await stopNetworkPolling(tester, network); + }, + ); + }); +} + +/// Asserts the geometry of the footer for the given configuration. +/// +/// Every footer element must lie inside the status line, must not intersect +/// another element, and must not be truncated. Each primary control must be +/// hit-testable. +void _expectFooterGeometry( + WidgetTester tester, { + required double width, + required bool connected, +}) { + final statusLine = tester.getRect(find.byType(StatusLine)); + final docs = _inStatusLine(find.byType(DocumentationLink)); + final connection = _connectionStatus(connected: connected); + final tutorial = _inStatusLine(find.text('Watch tutorial')); + final trigger = _trigger(); + + final elements = { + 'docs': tester.getRect(docs), + 'connection': tester.getRect(connection), + }; + if (trigger.evaluate().isNotEmpty) { + elements['trigger'] = tester.getRect(trigger); + } + if (tutorial.evaluate().isNotEmpty) { + elements['tutorial'] = tester.getRect(tutorial); + } + expectNoIntersections(elements); + expectWithinBounds(elements, statusLine); + + expectTextNotTruncated( + tester, + find.descendant(of: docs, matching: find.byType(Text)), + ); + final connectionText = connected + ? find.descendant(of: connection, matching: find.byType(Text)) + : _inStatusLine(find.text(_noClientConnection)); + if (connectionText.evaluate().isNotEmpty) { + expectTextNotTruncated(tester, connectionText); + } + + final inlineSecondary = width >= _inlineSecondaryItemsMinWidth; + expect(trigger, inlineSecondary ? findsNothing : findsOneWidget); + expect(tutorial, inlineSecondary ? findsWidgets : findsNothing); + expectInteractable(docs); + if (trigger.evaluate().isNotEmpty) { + expect(tester.getSize(trigger).width, actionWidgetSize); + expectInteractable(trigger); + } +} diff --git a/packages/devtools_app/test/screens/network/network_responsive_test.dart b/packages/devtools_app/test/screens/network/network_responsive_test.dart new file mode 100644 index 00000000000..ac3a8b1ba55 --- /dev/null +++ b/packages/devtools_app/test/screens/network/network_responsive_test.dart @@ -0,0 +1,327 @@ +// 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/src/shared/ui/file_import.dart'; +import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:devtools_test/helpers.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; + +import '../../test_infra/responsive_layout_utils.dart'; + +/// The width, in logical pixels, below which the search field is on its own +/// row. Matches the threshold in `NetworkScreen`. +const _searchOnOwnRowBelowWidth = 600.0; + +/// The width, in logical pixels, below which the filter field is on its own +/// row. Matches the threshold in `NetworkScreen`. +const _filterOnOwnRowBelowWidth = 900.0; + +Finder _inToolbar(Finder finder) { + return find.descendant(of: find.byType(NetworkScreenBody), matching: finder); +} + +/// Resizes the test window to [width] and pumps, rebuilding the screen at the +/// new size in the same widget tree. +Future _setWindowWidth(WidgetTester tester, double width) async { + tester.view.physicalSize = Size(width, responsiveLayoutHeight); + tester.view.devicePixelRatio = 1.0; + await tester.pump(); +} + +/// Whether the EditableText inside [field] holds primary focus. +bool _fieldHasFocus(WidgetTester tester, Finder field) { + final editable = find.descendant( + of: field, + matching: find.byType(EditableText), + ); + if (editable.evaluate().isEmpty) return false; + final focusNode = tester.widget(editable).focusNode; + return focusNode.hasFocus && + identical(focusNode, FocusManager.instance.primaryFocus); +} + +Finder get _search => _inToolbar(find.byType(SearchField)); + +Finder get _filter => + _inToolbar(find.byType(StandaloneFilterField)); + +Finder get _recordButton => _inToolbar(find.byType(StartStopRecordingButton)); + +/// Asserts the layout of the Network toolbar at [width]. +void _expectToolbarLayout(WidgetTester tester, {required double width}) { + final body = tester.getRect(find.byType(NetworkScreenBody)); + final search = tester.getRect(_search); + final filter = tester.getRect(_filter); + final record = tester.getRect(_recordButton); + final clear = tester.getRect(_inToolbar(find.byType(ClearButton))); + final export = tester.getRect(_inToolbar(find.byType(OpenSaveButtonGroup))); + + expectNoIntersections({ + 'record': record, + 'clear': clear, + 'search': search, + 'filter': filter, + 'export': export, + }); + expectWithinBounds({ + 'record': record, + 'clear': clear, + 'search': search, + 'filter': filter, + 'export': export, + }, body); + + // The field's own controls were the elements that overlapped in the report. + final fieldControls = { + for (final (i, control) in _inToolbar( + find.descendant( + of: _filter, + matching: find.byType(InputDecorationSuffixButton), + ), + ).evaluate().indexed) + 'filterSuffix$i': tester.getRect(find.byWidget(control.widget)), + }; + expect(fieldControls, isNotEmpty); + expectWithinBounds(fieldControls, filter); + expectNoIntersections(fieldControls); + + if (width < _searchOnOwnRowBelowWidth) { + expect(search.top, greaterThanOrEqualTo(record.bottom - geometryTolerance)); + } else { + expect(search.center.dy, closeTo(record.center.dy, geometryTolerance)); + } + if (width < _filterOnOwnRowBelowWidth) { + expect(filter.top, greaterThanOrEqualTo(search.bottom - geometryTolerance)); + } else { + expect(filter.center.dy, closeTo(search.center.dy, geometryTolerance)); + } + + [_recordButton, _search, _filter].forEach(expectInteractable); +} + +void main() { + setUpAll(loadFonts); + + late FakeServiceConnectionManager connection; + late NetworkController network; + late LoggingController logging; + late DebuggerController debugger; + + setUp(() { + connection = createLayoutServiceConnection(); + setUpLayoutGlobals(connection); + network = NetworkController(); + logging = LoggingController(); + debugger = DebuggerController(); + }); + + for (final embedded in [false, true]) { + group('Network toolbar (${embedded ? 'embedded' : 'standalone'})', () { + for (final width in responsiveLayoutWidths) { + testWidgetsWithWindowSize( + 'has no overlap or clipping at $width', + Size(width, responsiveLayoutHeight), + (WidgetTester tester) async { + final errors = await collectFlutterErrors( + () => pumpDevToolsScaffold( + tester, + pageId: ScreenMetaData.network.id, + embedded: embedded, + connected: true, + connection: connection, + network: network, + logging: logging, + debugger: debugger, + ), + ); + expect(errors, isEmpty, reason: errors.join('\n')); + _expectToolbarLayout(tester, width: width); + await stopNetworkPolling(tester, network); + }, + ); + } + }); + } + + group('Network toolbar state across resizes', () { + // Resizing across the breakpoints reparents the search and filter fields + // between rows. Their stable keys keep each field's state attached, so + // text, selection, and focus survive the move. + testWidgetsWithWindowSize( + 'search and filter keep text, selection, and focus across resizes', + const Size(650, responsiveLayoutHeight), + (WidgetTester tester) async { + final errors = await collectFlutterErrors( + () => pumpDevToolsScaffold( + tester, + pageId: ScreenMetaData.network.id, + embedded: false, + connected: true, + connection: connection, + network: network, + logging: logging, + debugger: debugger, + ), + ); + expect(errors, isEmpty, reason: errors.join('\n')); + + final searchEditable = find.descendant( + of: _search, + matching: find.byType(EditableText), + ); + final filterEditable = find.descendant( + of: _filter, + matching: find.byType(EditableText), + ); + + await tester.enterText(searchEditable, 'method:GET'); + await tester.enterText(filterEditable, 'status:200'); + tester.widget(searchEditable).controller.selection = + const TextSelection(baseOffset: 2, extentOffset: 7); + tester.widget(filterEditable).controller.selection = + const TextSelection(baseOffset: 1, extentOffset: 4); + await tester.pump(); + + // Focus the filter field so the test can catch a focus steal by the + // search field's autofocus when it is remounted. + await tester.showKeyboard(filterEditable); + await tester.pump(); + + final searchFieldState = tester.state(_search); + final searchEditableState = tester.state(searchEditable); + final filterFieldState = tester.state(_filter); + final filterEditableState = tester.state(filterEditable); + final searchCtl = tester + .widget(searchEditable) + .controller; + final filterCtl = tester + .widget(filterEditable) + .controller; + final searchText = searchCtl.value.text; + final searchSelection = searchCtl.value.selection; + final filterText = filterCtl.value.text; + final filterSelection = filterCtl.value.selection; + final activeQuery = network.activeFilter.value.queryFilter.query; + + expect(searchText, 'method:GET'); + expect(filterText, 'status:200'); + expect(network.search, 'method:GET'); + expect(_fieldHasFocus(tester, _filter), isTrue); + expect(_fieldHasFocus(tester, _search), isFalse); + + // Cross each boundary in both directions in the same tree, and finish + // on a wide desktop baseline. + for (final width in [599.0, 600.0, 899.0, 900.0, 899.0, 1400.0]) { + final resizeErrors = await collectFlutterErrors( + () => _setWindowWidth(tester, width), + ); + expect(resizeErrors, isEmpty, reason: resizeErrors.join('\n')); + + expect( + tester.state(_search), + same(searchFieldState), + reason: 'SearchField state lost at $width', + ); + expect( + tester.state(searchEditable), + same(searchEditableState), + reason: 'search EditableText state lost at $width', + ); + expect( + tester.state(_filter), + same(filterFieldState), + reason: 'StandaloneFilterField state lost at $width', + ); + expect( + tester.state(filterEditable), + same(filterEditableState), + reason: 'filter EditableText state lost at $width', + ); + expect( + tester.widget(searchEditable).controller, + same(searchCtl), + reason: 'search text controller replaced at $width', + ); + expect( + tester.widget(filterEditable).controller, + same(filterCtl), + reason: 'filter text controller replaced at $width', + ); + expect(searchCtl.value.text, searchText); + expect(searchCtl.value.selection, searchSelection); + expect(filterCtl.value.text, filterText); + expect(filterCtl.value.selection, filterSelection); + expect(network.search, 'method:GET'); + expect(network.activeFilter.value.queryFilter.query, activeQuery); + expect( + _fieldHasFocus(tester, _filter), + isTrue, + reason: 'filter focus lost at $width', + ); + expect( + _fieldHasFocus(tester, _search), + isFalse, + reason: 'remounted search field stole focus at $width', + ); + } + + // The wide baseline still places both fields in the top row. + final search = tester.getRect(_search); + final filter = tester.getRect(_filter); + final record = tester.getRect(_recordButton); + expect(search.center.dy, closeTo(record.center.dy, geometryTolerance)); + expect(filter.center.dy, closeTo(search.center.dy, geometryTolerance)); + + await stopNetworkPolling(tester, network); + }, + ); + }); + + group('Network toolbar behavior', () { + for (final width in [328.0, 900.0, 1400.0]) { + testWidgetsWithWindowSize( + 'search, filter, and recording work at $width', + Size(width, responsiveLayoutHeight), + (WidgetTester tester) async { + await pumpDevToolsScaffold( + tester, + pageId: ScreenMetaData.network.id, + embedded: false, + connected: true, + connection: connection, + network: network, + logging: logging, + debugger: debugger, + ); + + await tester.enterText( + find.descendant(of: _search, matching: find.byType(EditableText)), + 'GET', + ); + expect(network.search, 'GET'); + + await tester.enterText( + find.descendant(of: _filter, matching: find.byType(EditableText)), + 'http', + ); + expect(network.activeFilter.value.queryFilter.query, 'http'); + + final wasRecording = network.recordingNotifier.value; + await tester.tap(_recordButton); + await tester.pump(const Duration(seconds: 1)); + expect(network.recordingNotifier.value, !wasRecording); + + await stopNetworkPolling(tester, network); + }, + ); + } + }); +} diff --git a/packages/devtools_app/test/test_infra/responsive_layout_utils.dart b/packages/devtools_app/test/test_infra/responsive_layout_utils.dart new file mode 100644 index 00000000000..853d5c14b86 --- /dev/null +++ b/packages/devtools_app/test/test_infra/responsive_layout_utils.dart @@ -0,0 +1,299 @@ +// 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 'dart:async'; + +import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/framework/scaffold/scaffold.dart'; +import 'package:devtools_app/src/shared/analytics/constants.dart' as gac; +import 'package:devtools_app/src/shared/framework/framework_controller.dart'; +import 'package:devtools_app/src/shared/managers/survey.dart'; +import 'package:devtools_app/src/shared/primitives/message_bus.dart'; +import 'package:devtools_app_shared/service.dart'; +import 'package:devtools_app_shared/shared.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/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:logging/logging.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:mockito/mockito.dart'; +import 'package:provider/provider.dart'; + +import '../screens/network/utils/network_test_utils.dart'; + +/// The viewport widths, in logical pixels, used by the responsive layout tests. +/// +/// These include the realistic narrow widths from the issue (328 and 411), the +/// `MediaSize` boundaries used by the Network toolbar and the status line (600 +/// and 900), and a desktop baseline. +const responsiveLayoutWidths = [ + 328, + 411, + 599, + 600, + 601, + 899, + 900, + 901, + 1400, +]; + +/// The viewport height, in logical pixels, used by the responsive layout tests. +const responsiveLayoutHeight = 900.0; + +/// The tolerance, in logical pixels, used when comparing geometry. +const geometryTolerance = 0.5; + +/// Creates a fake service connection to a Dart VM app, with the VM details that +/// the About dialog reads. +FakeServiceConnectionManager createLayoutServiceConnection() { + final connection = FakeServiceConnectionManager( + service: FakeServiceManager.createFakeService( + socketProfile: loadSocketProfile(), + httpProfile: loadHttpProfile(), + ), + ); + final vm = connection.serviceManager.vm; + when(vm.version).thenReturn('1.9.1'); + when(vm.targetCPU).thenReturn('arm64'); + when(vm.architectureBits).thenReturn(64); + when(vm.operatingSystem).thenReturn('android'); + mockConnectedApp( + connection.serviceManager.connectedApp!, + isFlutterApp: false, + ); + for (final screenId in [ + ScreenMetaData.network.id, + ScreenMetaData.logging.id, + ]) { + when( + connection.errorBadgeManager.errorCountNotifier(screenId), + ).thenReturn(ValueNotifier(0)); + } + return connection; +} + +/// Registers the globals that a full [DevToolsScaffold] needs in tests. +void setUpLayoutGlobals(FakeServiceConnectionManager connection) { + setGlobal(ServiceConnectionManager, connection); + setGlobal(OfflineDataController, OfflineDataController()); + setGlobal(IdeTheme, IdeTheme()); + setGlobal( + DevToolsEnvironmentParameters, + ExternalDevToolsEnvironmentParameters(), + ); + setGlobal(PreferencesController, PreferencesController()); + setGlobal(NotificationService, NotificationService()); + setGlobal(DTDManager, MockDTDManager()); + setGlobal(FrameworkController, FrameworkController()); + setGlobal(SurveyService, SurveyService()); + setGlobal(BannerMessagesController, BannerMessagesController()); + setGlobal(BreakpointManager, BreakpointManager()); + setGlobal(MessageBus, MessageBus()); +} + +/// Pumps a full [DevToolsScaffold] showing the screen identified by [pageId]. +/// +/// The scaffold contains the real application-wide `StatusLine`, so the footer +/// and the screen body are laid out together as they are in the app. +Future pumpDevToolsScaffold( + WidgetTester tester, { + required String pageId, + required bool embedded, + required bool connected, + required FakeServiceConnectionManager connection, + required NetworkController network, + required LoggingController logging, + required DebuggerController debugger, +}) async { + final embedMode = embedded ? EmbedMode.embedOne : EmbedMode.none; + setGlobal(IdeTheme, IdeTheme(embedMode: embedMode)); + connection.serviceManager.setConnectedState(connected); + // The providers sit above the app so that dialogs opened from the footer can + // read them, as they do in the app. + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value( + value: AnalyticsController( + enabled: false, + shouldShowConsentMessage: false, + consentMessage: 'fake message', + ), + ), + Provider.value(value: ReleaseNotesController()), + ], + child: wrapWithControllers( + DevToolsScaffold( + screens: [NetworkScreen(), LoggingScreen()], + page: pageId, + embedMode: embedMode, + ), + network: network, + logging: logging, + debugger: debugger, + ), + ), + ); + await tester.pump(const Duration(seconds: 1)); +} + +/// Runs [action] and returns the message of every [FlutterError] it reports. +/// +/// Layout exceptions are reported through [FlutterError.onError]. Collecting +/// them lets tests assert on them after the handler is restored. +Future> collectFlutterErrors( + Future Function() action, +) async { + final errors = []; + final originalOnError = FlutterError.onError; + FlutterError.onError = errors.add; + try { + await action(); + } finally { + FlutterError.onError = originalOnError; + } + return [for (final error in errors) error.exceptionAsString()]; +} + +/// Stops the polling of [network] and lets pending timers complete, so that no +/// timers are left pending when a test ends. +Future stopNetworkPolling( + WidgetTester tester, + NetworkController network, +) async { + await network.stopRecording(); + await tester.pumpAndSettle(const Duration(seconds: 1)); +} + +/// Asserts that no two rectangles in [named] intersect. +void expectNoIntersections(Map named) { + final entries = named.entries.toList(); + for (var i = 0; i < entries.length; i++) { + for (var j = i + 1; j < entries.length; j++) { + final a = entries[i]; + final b = entries[j]; + expect( + a.value + .deflate(geometryTolerance) + .overlaps(b.value.deflate(geometryTolerance)), + isFalse, + reason: '${a.key} ${a.value} intersects ${b.key} ${b.value}', + ); + } + } +} + +/// Asserts that every rectangle in [named] lies entirely within [bounds]. +/// +/// A rectangle that extends past [bounds] is clipped or overflowing. +void expectWithinBounds(Map named, Rect bounds) { + for (final entry in named.entries) { + final rect = entry.value; + expect( + rect.width > 0 && rect.height > 0, + isTrue, + reason: '${entry.key} has no size: $rect', + ); + expect( + rect.left >= bounds.left - geometryTolerance && + rect.right <= bounds.right + geometryTolerance && + rect.top >= bounds.top - geometryTolerance && + rect.bottom <= bounds.bottom + geometryTolerance, + isTrue, + reason: '${entry.key} $rect is not within $bounds', + ); + } +} + +/// Asserts that the text in [finder] is laid out at its full intrinsic width, +/// so that it is not ellipsized or wrapped. +void expectTextNotTruncated(WidgetTester tester, Finder finder) { + final paragraph = tester.renderObject(finder); + final fullWidth = paragraph.getMaxIntrinsicWidth(double.infinity); + expect( + paragraph.size.width + geometryTolerance >= fullWidth, + isTrue, + reason: + 'Text "${paragraph.text.toPlainText()}" is truncated: laid out at ' + '${paragraph.size.width}, needs $fullWidth', + ); +} + +/// Records the URLs that DevTools asks the platform to open. +/// +/// Launching is intercepted at the `url_launcher` method channel, so the test +/// checks the URL without opening a browser. +class UrlLaunchRecorder { + final launched = []; + + void install() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + switch (call.method) { + case 'canLaunch': + return true; + case 'launch': + launched.add((call.arguments as Map)['url'] as String); + return true; + } + return null; + }); + } + + void dispose() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, null); + } + + static const _channel = MethodChannel('plugins.flutter.io/url_launcher'); +} + +/// Records the analytics events that DevTools reports. +/// +/// The analytics implementation used in tests logs each selection, so the +/// events are captured from the log rather than from a network request. +class AnalyticsRecorder { + final events = []; + + StreamSubscription? _subscription; + Level? _originalLevel; + + void install() { + _originalLevel = Logger.root.level; + Logger.root.level = Level.ALL; + _subscription = Logger.root.onRecord.listen((record) { + if (record.loggerName == '_analytics_stub') { + events.add(record.message); + } + }); + } + + void dispose() { + unawaited(_subscription?.cancel()); + Logger.root.level = _originalLevel ?? Level.INFO; + } + + /// Returns whether a selection of [item] on [screenId] was recorded. + bool recordedSelection({required String screenId, required String item}) { + return events.any( + (event) => event.contains('screenName:$screenId, selectedItem:$item, '), + ); + } + + /// The selected item description for the tutorial link on [screenId]. + static String tutorialItem(String screenId) { + return '${gac.videoTutorialLink}-$screenId'; + } +} + +/// Asserts that [finder] is hit-testable, so a user tapping its center reaches +/// it. +void expectInteractable(Finder finder) { + expect(finder.hitTestable(), findsOneWidget); +}