From 156eefc3e44fe7c11f0214b806d6057ae45670a0 Mon Sep 17 00:00:00 2001 From: Harrys76 Date: Mon, 19 Oct 2026 15:14:03 +0700 Subject: [PATCH 1/7] feat: header run label, More menu and status row for the 1.0.0 card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header shows a run label ("Debug · VM", "Profile · no VM") in place of the VM+, FRAME and DBG badges, and steps down by measuring its text: without the "Sleuth" name, then the mode only, then the dot, which is filled with the VM and a ring without it. A More button replaces the theme button. Its panel inside the card holds Startup, Rebuild stats, Hidden issues, Encyclopedia, Guide, Copy snapshot and the Auto/Light/Dark theme segments, with values read once when it opens. Back and Escape close a page, then the menu, then the card; a tap on the scrim or the app closes it; the app and the card body are hidden from screen readers while it is open. Keyboard focus moves to the first row that can act and returns to More; the controls that take keyboard focus draw an outline in keyboard mode. The footer, the startup and rebuild stats banners, the confidence caption and the frame-rate detail row are gone. The severity chips lead the status row, with the frame rate at its end, and "Showing X of Y" follows the severity filter only. The card is at least 320 px wide (less on narrow screens), its background is opaque, and a new cardDivider token draws the line under the status row. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 35 + doc/capture_procedure.md | 3 +- example/lib/demos/rebuild_hotspot_demo.dart | 18 +- lib/src/controller/sleuth_controller.dart | 4 +- .../debug_instrumentation_coordinator.dart | 19 +- lib/src/ui/floating_issues_card.dart | 3095 +++++++---------- lib/src/ui/focus_ink_well.dart | 97 + lib/src/ui/guide_page.dart | 59 +- lib/src/ui/more_menu.dart | 458 +++ lib/src/ui/rebuild_stats_page.dart | 4 +- lib/src/ui/sleuth_overlay.dart | 45 +- lib/src/ui/sleuth_theme.dart | 60 +- ...trumentation_coordinator_profile_test.dart | 2 +- test/debug/overlay_denylist_audit_test.dart | 18 +- test/helpers/overlay_harness.dart | 26 + test/ui/accessibility_guideline_test.dart | 81 +- test/ui/back_gesture_test.dart | 26 +- test/ui/card_resize_test.dart | 185 +- test/ui/dashboard_dbg_badge_test.dart | 84 - test/ui/dashboard_export_test.dart | 67 +- test/ui/dashboard_run_label_test.dart | 161 + test/ui/dashboard_summary_test.dart | 74 +- test/ui/duplicate_stable_id_test.dart | 10 +- test/ui/floating_issues_card_test.dart | 935 +++-- test/ui/guide_page_test.dart | 2 +- test/ui/overlay_focus_test.dart | 106 +- test/ui/overlay_hide_filter_test.dart | 175 +- test/ui/overlay_semantics_test.dart | 66 +- test/ui/overlay_toast_test.dart | 43 +- test/ui/sleuth_theme_test.dart | 56 +- test/ui/text_scale_test.dart | 81 +- test/ui/text_scaler_clamp_test.dart | 8 +- 32 files changed, 3144 insertions(+), 2959 deletions(-) create mode 100644 lib/src/ui/focus_ink_well.dart create mode 100644 lib/src/ui/more_menu.dart delete mode 100644 test/ui/dashboard_dbg_badge_test.dart create mode 100644 test/ui/dashboard_run_label_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index a344f36b..99fadc08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,9 +51,44 @@ - `DebugSnapshot.forcedRebuildsOf` (debug callbacks): for each widget type a parent's build rebuilt, the types whose builds forced it, framework widgets included, and how often. `forcedTypesCapped` reports its caps. +- `SleuthThemeData.cardDivider`, the line under the status row. It + defaults to `0xFF4B5563` in dark and `0xFFB4BAC4` in light, `border` in + the high-contrast presets and `outlineVariant` in `fromColorScheme`. ### Changed +- The card header shows a run label, such as "Debug · VM" or "Profile · no + VM", in place of the VM+, FRAME and DBG badges. A narrow card drops the + "Sleuth" name first, then "· VM", and at last keeps only the dot, which + is filled with the VM and a ring without it. A screen reader always + hears the full label. +- A More button replaces the theme button. Its menu holds Startup (TTFF), + Rebuild stats (the route's count, opening the drilldown), Hidden issues + (with a count, only when there are any), Encyclopedia, Guide, Copy + snapshot and Theme (Auto, Light, Dark). It opens as a panel inside the + card. Back, Escape, a tap on the scrim or a tap on the app closes it, + and while it is open the app and the card body are hidden from screen + readers. Opening it moves keyboard focus to the first row that can act + and screen reader focus to the panel. More, the rows and the theme + segments draw a 2 px `checkboxActive` outline while the keyboard has + focus. A failed copy offers Try again. +- The footer, the startup banner, the rebuild stats banner, the confidence + caption and the frame-rate detail row are gone. Hidden issues, Copy + snapshot, startup and rebuild stats moved into the More menu. +- The severity chips lead the status row ("2 critical"), with a filled + icon while on and an outlined one while off. The frame rate sits at the + end of the row. "Showing X of Y" appears only while a severity is off, + since the More menu counts hidden cards. A screen reader hears the line + change, and Show all moves its focus to the first chip. +- The card is at least 320 px wide, or the screen less 16 px margins on + a narrower screen, and stored narrower widths are raised. Its background + is opaque. +- A maximized card draws the resize grip's dots only, and a drag there + reaches the list. The list ends with 48 px of padding, so the grip does + not cover the last card. +- The overlay no longer uses `TweenAnimationBuilder`, so it is off the + debug instrumentation's framework denylist and an app's own uses count. + - `FrameVerdict.relatedIssues` lists only the issues that can explain the verdict's phase: the issues of the phase's category, `event_loop_stall` for a start delay, and none for a stall or an unknown phase. Jank diff --git a/doc/capture_procedure.md b/doc/capture_procedure.md index 5269af37..adb169a9 100644 --- a/doc/capture_procedure.md +++ b/doc/capture_procedure.md @@ -15,7 +15,8 @@ > 1. `cd example && fvm flutter run --profile --no-dds -d "iPhone 12" \ > --dart-define=SLEUTH_CAPTURE_MODE=true \ > --dart-define=SLEUTH_CAPTURE_DEVICE="iPhone 12"`. This installs -> and starts the app. The status row shows VM+ once Sleuth connects. +> and starts the app. The header run label reads "· VM" once Sleuth +> connects. > 2. If you launched without `--no-dds`, quit `flutter run` (`q`). DDS > stops with it. > 3. In that case, reopen the app from the iPhone home screen. No DDS diff --git a/example/lib/demos/rebuild_hotspot_demo.dart b/example/lib/demos/rebuild_hotspot_demo.dart index e5fb26a3..443ce92f 100644 --- a/example/lib/demos/rebuild_hotspot_demo.dart +++ b/example/lib/demos/rebuild_hotspot_demo.dart @@ -23,13 +23,13 @@ import '../demo_scaffold.dart'; /// /// 1. Open the Sleuth overlay (paw icon). /// 2. Tap "Start Ticker" and wait a few seconds. -/// 3. The "Rebuilds: N across M widgets" banner at the top of the card -/// counts every widget type built on this screen; tap it to expand -/// the top three. -/// 4. Tap "See all M" to open the Rebuild stats page, every -/// widget type sorted by count. +/// 3. Open the card's More menu: Rebuild stats counts every widget +/// built on this screen. +/// 4. Tap Rebuild stats to open the Rebuild stats page, every widget +/// type sorted by count. /// -/// In debug mode the banner stays empty (it reads the profile timeline). +/// In debug mode the row reads "No counts yet" (it reads the profile +/// timeline). /// Sleuth instead counts a rebuild for the widget that started it: the /// dashboard's `ValueListenableBuilder` and the metric chips' /// builders rebuild about 30 times a second together, the builder alert @@ -104,9 +104,9 @@ class _RebuildHotspotDemoState extends State { 'subscribes to the tick notifier, so each tick rebuilds one ' 'widget.\n\n' 'Tap "Start Ticker", open the Sleuth overlay (paw icon) and ' - 'wait a few seconds. In PROFILE mode the "Rebuilds" banner at the ' - 'top of the card counts the widgets built on this screen; expand ' - 'it and tap "See all" for the Rebuild stats page. In DEBUG ' + 'wait a few seconds. In PROFILE mode Rebuild stats in the card\'s ' + 'More menu counts the widgets built on this screen. Tap it for ' + 'the Rebuild stats page. In DEBUG ' 'mode Sleuth names the widget that starts each rebuild: a card ' 'for ValueListenableBuilder, whose detail counts the ' 'dashboard widgets it rebuilt.', diff --git a/lib/src/controller/sleuth_controller.dart b/lib/src/controller/sleuth_controller.dart index c8aac49f..8e993667 100644 --- a/lib/src/controller/sleuth_controller.dart +++ b/lib/src/controller/sleuth_controller.dart @@ -6139,8 +6139,8 @@ class SleuthConfig { /// Sleuth installs a `FlutterTimeline.debugCollect()` drain that records /// per-widget rebuild counts and attributes them to the active /// `RouteSession`'s `rebuildCountsByType` map. These counts power the - /// always-on `_RebuildStatsBanner` panel on the floating issues card - /// and the "See all N →" drilldown page (`RebuildStatsPage`). + /// More menu's Rebuild stats row on the floating issues card and its + /// drilldown page (`RebuildStatsPage`). /// **Note:** profile-mode counts include initial widget inflations as /// well as `setState`-driven rebuilds (the Flutter framework emits the /// same `BUILD` timeline scope for both), so route entry shows diff --git a/lib/src/debug/debug_instrumentation_coordinator.dart b/lib/src/debug/debug_instrumentation_coordinator.dart index 93f7348c..583c1381 100644 --- a/lib/src/debug/debug_instrumentation_coordinator.dart +++ b/lib/src/debug/debug_instrumentation_coordinator.dart @@ -752,7 +752,7 @@ class DebugInstrumentationCoordinator { /// uses Sleuth-named equivalents listed below, so app-owned rebuilds of /// those widgets are counted. static const Set _frameworkWidgetDenyList = { - // --- Flutter framework widgets used in lib/src/ui/ (53) --- + // --- Flutter framework widgets used in lib/src/ui/ (52) --- 'Align', 'AnimatedBuilder', 'AnimatedRotation', @@ -803,20 +803,13 @@ class DebugInstrumentationCoordinator { 'Stack', 'Text', 'TextField', - 'TweenAnimationBuilder', 'ValueListenableBuilder', 'Wrap', // --- Sleuth overlay widget classes (43) --- 'FloatingIssuesCard', '_StatusRow', - '_ThroughputDetailRow', - '_FpsCell', '_DebugModeBanner', '_WarningBanners', - '_CardFooter', - '_IssuesSummaryBar', - '_StartupMetricsBanner', - '_RebuildStatsBanner', 'TriggerButton', 'SleuthOverlay', 'HighlightOverlay', @@ -843,13 +836,19 @@ class DebugInstrumentationCoordinator { '_HiddenRow', '_TextAction', '_SeverityChip', - '_SeverityChipPill', '_EmptyListMessage', '_ToastFade', '_TriggerLayout', 'SleuthListenableBuilder', '_AppSemanticsBlocker', - '_LeadTrailLine', + 'SleuthMoreMenu', + '_MenuRow', + '_ThemeRow', + '_FpsText', + '_RunLabel', + '_ShowingRow', + 'SleuthFocusInkWell', + 'SleuthFocusOutline', }; void _handleRebuildDirtyWidget(Element element, bool builtOnce) { diff --git a/lib/src/ui/floating_issues_card.dart b/lib/src/ui/floating_issues_card.dart index 65a870ef..79f7ddd5 100644 --- a/lib/src/ui/floating_issues_card.dart +++ b/lib/src/ui/floating_issues_card.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart' show GestureBinding; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; @@ -16,6 +17,8 @@ import '../models/widget_highlight.dart'; import 'issue_card.dart'; import 'ai_chat_page.dart'; import 'issue_encyclopedia_page.dart'; +import 'focus_ink_well.dart'; +import 'more_menu.dart'; import 'guide_page.dart'; import 'rebuild_stats_page.dart'; import 'startup_metrics_page.dart'; @@ -25,7 +28,6 @@ import '../utils/issue_explanation_builder.dart'; import '../utils/own_work_log.dart'; import '../vm/connection_mode.dart'; import 'hidden_issues_page.dart'; -import 'motion.dart'; import 'overlay_filters.dart'; import 'overlay_toast.dart'; import 'overlay_ui_state.dart'; @@ -289,13 +291,17 @@ class FloatingIssuesCard extends StatefulWidget { /// [closeInnermostLayer] on system back before closing the dashboard. abstract interface class OverlayLayerHost { /// Closes the innermost open layer: unfocuses a focused text field, - /// else closes the open full-screen page, else the Hidden list. Returns - /// false when none of these is open. + /// else closes the open full-screen page, else the Hidden list, else + /// the More menu. Returns false when none of these is open. bool closeInnermostLayer(); /// Number of full-screen layers (pages and the Hidden list) open above /// the card. Read from [FloatingIssuesCard.onLayersChanged]. int get openLayerDepth; + + /// Whether the More menu is open inside the card. Read from + /// [FloatingIssuesCard.onLayersChanged]. + bool get menuOpen; } /// A full-screen layer over the card. @@ -444,6 +450,39 @@ class _FloatingIssuesCardState extends State /// the dashboard opens. final GlobalKey _headerKey = GlobalKey(debugLabel: 'Sleuth header'); + /// The More menu, open inside the card over a scrim, and what it shows, + /// read when it opened. + bool _menuOpen = false; + MoreMenuValues? _menuValues; + + /// Menu open state reported through [FloatingIssuesCard.onLayersChanged]. + bool _reportedMenuOpen = false; + + /// Keeps keyboard focus in the menu while it is open. + final FocusScopeNode _menuScope = FocusScopeNode( + debugLabel: 'Sleuth More menu', + ); + + /// The More button's focus and semantics node: focus returns there + /// when the menu, or a page opened from it, closes. + final FocusNode _moreFocus = FocusNode(debugLabel: 'Sleuth More'); + final GlobalKey _moreKey = GlobalKey(debugLabel: 'Sleuth More'); + + /// The menu panel's semantics node, which takes screen reader focus + /// when the menu opens. + final GlobalKey _menuKey = GlobalKey(debugLabel: 'Sleuth More menu'); + + /// The first severity chip, which takes screen reader focus when Show + /// all or Reset, the control that held it, goes away. + final GlobalKey _firstChipKey = GlobalKey(debugLabel: 'Sleuth chip'); + + /// The card with its grip and menu; a pointer down outside it closes the + /// menu. + final GlobalKey _cardKey = GlobalKey(debugLabel: 'Sleuth card'); + + /// [_layerOpener] value of a page opened from the More menu. + static const String _moreOpener = 'Sleuth More'; + /// Scroll position of the issue list, kept while a page covers the card /// (the list unmounts under it). final _IssueListScrollController _listScroll = _IssueListScrollController(); @@ -482,10 +521,19 @@ class _FloatingIssuesCardState extends State Timer? _jankHoldTimer; double _cardWidth = _defaultCardWidth; - static const double _defaultCardWidth = 300; - static const double _minCardWidth = 220; + static const double _defaultCardWidth = 320; + static const double _minCardWidth = 320; static const double _minCardHeight = 300; + /// Smallest card width on a screen of [size] with safe area [safe]: + /// [_minCardWidth], or the safe area minus 16 px margins when that is + /// narrower. + static double _minWidthFor(Size size, EdgeInsets safe) => + math.max(0.0, math.min(_minCardWidth, size.width - safe.horizontal - 32)); + + /// [_minWidthFor] the screen of the last build; 0 before it. + double _cachedMinWidth = 0; + /// Height of the minimized card: the 48 px header row. static const double _minimizedHeight = 48; @@ -495,8 +543,10 @@ class _FloatingIssuesCardState extends State /// Height of the card header. static const double _headerHeight = _controlRowHeight; - /// Height of the card footer: a control row and its top border. - static const double _footerHeight = _controlRowHeight + 1; + /// Smallest height of the status row at [chromeScale], with the divider + /// below it. + static double _statusHeightFor(double chromeScale) => + _controlRowHeight * chromeScale + 1; // ─── Window state ────────────────────────────────────────────────── CardWindowState _windowState = CardWindowState.normal; @@ -585,6 +635,14 @@ class _FloatingIssuesCardState extends State /// Writes the local geometry to [OverlayUiState]: once per drag end, /// resize end, or window-state change. void _commitGeometry() { + // A width stored below the minimum (an older release allowed 220 px) + // is raised here and at layout, not when the state is read. + final minWidth = _cachedMinWidth; + if (_cardWidth < minWidth) _cardWidth = minWidth; + final restoreWidth = _preTransitionWidth; + if (restoreWidth != null && restoreWidth < minWidth) { + _preTransitionWidth = minWidth; + } _committingGeometry = true; try { _ui.setCardGeometry( @@ -673,7 +731,7 @@ class _FloatingIssuesCardState extends State event.logicalKey != LogicalKeyboardKey.escape) { return false; } - if (openLayerDepth == 0 && _appTakesEscape()) return false; + if (openLayerDepth == 0 && !_menuOpen && _appTakesEscape()) return false; if (!closeInnermostLayer()) widget.onClose(); return true; } @@ -693,6 +751,11 @@ class _FloatingIssuesCardState extends State @override void dispose() { + if (_menuOpen) { + GestureBinding.instance.pointerRouter.removeGlobalRoute(_onGlobalPointer); + } + _menuScope.dispose(); + _moreFocus.dispose(); HardwareKeyboard.instance.removeHandler(_onKeyEvent); SemanticsBinding.instance.removeSemanticsEnabledListener(_syncToastTiming); widget.controller.verdictNotifier.removeListener(_onVerdictChanged); @@ -804,6 +867,7 @@ class _FloatingIssuesCardState extends State void _minimize() { if (_windowState == CardWindowState.minimized) return; + _closeMenu(refocus: false); setState(() { _preTransitionOffset ??= _cardOffset; _preTransitionWidth ??= _cardWidth; @@ -816,6 +880,7 @@ class _FloatingIssuesCardState extends State void _maximize(BuildContext context) { if (_windowState == CardWindowState.maximized) return; + _closeMenu(refocus: false); final size = MediaQuery.sizeOf(context); final safe = MediaQuery.viewPaddingOf(context); final keyboard = MediaQuery.viewInsetsOf(context).bottom; @@ -847,6 +912,7 @@ class _FloatingIssuesCardState extends State ); void _restore() { + _closeMenu(refocus: false); setState(() { if (_preTransitionOffset != null) _cardOffset = _preTransitionOffset; if (_preTransitionWidth != null) _cardWidth = _preTransitionWidth!; @@ -897,6 +963,14 @@ class _FloatingIssuesCardState extends State void _refocusOpener() { final opener = _layerOpener; _layerOpener = null; + if (opener == _moreOpener) { + _focusSemantics(_moreKey); + // Keyboard focus goes back to More unless something holds it; the + // overlay gives an app node that had focus its focus back. + final focus = FocusManager.instance.primaryFocus; + if (focus == null || focus is FocusScopeNode) _moreFocus.requestFocus(); + return; + } RenderObject? target; if (opener != null) { final wanted = ValueKey(opener); @@ -960,12 +1034,109 @@ class _FloatingIssuesCardState extends State setState(() => _showGuide = false); } else if (_showHidden) { setState(() => _showHidden = false); + } else if (_menuOpen) { + _closeMenu(); } else { return false; } return true; } + // ─── More menu ───────────────────────────────────────────────────── + + @override + bool get menuOpen => _menuOpen; + + void _toggleMenu() => _menuOpen ? _closeMenu() : _openMenu(); + + /// Opens the menu with the startup time, the route's rebuild count and + /// the hidden count as they are now. A pointer down outside the card + /// closes it again (see [_onGlobalPointer]). + void _openMenu() { + if (_menuOpen) return; + final counts = widget.controller.activeRouteSession?.rebuildCountsByType; + final rebuilds = counts == null + ? 0 + : counts.values.fold(0, (a, b) => a + b); + setState(() { + _menuOpen = true; + _menuValues = ( + ttffMs: Sleuth.startupMetrics?.ttffMs, + rebuildTotal: rebuilds > 0 ? rebuilds : null, + hiddenCount: + _ui.hiddenKeys.length + + widget.controller.suppressedCountNotifier.value, + ); + }); + GestureBinding.instance.pointerRouter.addGlobalRoute(_onGlobalPointer); + // Keyboard focus moves into the panel, from the app too: autofocus + // alone does not take focus another scope holds. + // The first enabled row takes keyboard focus, so Enter acts at once, + // and the screen reader moves to the panel. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_menuOpen) return; + _menuScope.requestFocus(); + _menuScope.nextFocus(); + _focusSemantics(_menuKey); + }); + } + + /// Closes the menu. With [refocus], keyboard and screen reader focus + /// return to the More button; a tap that closed it elsewhere keeps its + /// own focus. + void _closeMenu({bool refocus = true}) { + if (!_menuOpen) return; + GestureBinding.instance.pointerRouter.removeGlobalRoute(_onGlobalPointer); + setState(() { + _menuOpen = false; + _menuValues = null; + }); + if (refocus) { + _focusMore(); + } else if (_menuScope.hasFocus) { + _menuScope.unfocus(); + } + } + + /// Moves keyboard and screen reader focus to the More button. + void _focusMore() { + _moreFocus.requestFocus(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _focusSemantics(_moreKey); + }); + } + + /// A pointer down on the app closes the menu and still reaches the app; + /// the route exists only while the menu is open. A pointer down on the + /// card is left to the scrim and the header. + void _onGlobalPointer(PointerEvent event) { + if (event is! PointerDownEvent || !_menuOpen) return; + final box = _cardKey.currentContext?.findRenderObject(); + if (box is RenderBox && box.attached && box.hasSize) { + final local = box.globalToLocal(event.position); + if ((Offset.zero & box.size).contains(local)) return; + } + _closeMenu(refocus: false); + } + + /// Turns every severity back on and moves screen reader focus to the + /// first chip, since the Show all or Reset control that held it goes. + void _showAllSeverities() { + _ui.resetSeverityFilter(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _focusSemantics(_firstChipKey); + }); + } + + /// Closes the menu, then opens a page with More recorded as the opener. + void _openFromMenu(VoidCallback open) { + _closeMenu(refocus: false); + setState(() { + open(); + _layerOpener = _moreOpener; + }); + } + void _closeAiChat() => setState(() { _showAiChat = false; _chatIssueKey = null; @@ -1034,25 +1205,6 @@ class _FloatingIssuesCardState extends State if (mounted) _toast.show('Copied'); } - static String _themeModeLabel(SleuthThemeMode mode) => switch (mode) { - SleuthThemeMode.system => 'System', - SleuthThemeMode.light => 'Light', - SleuthThemeMode.dark => 'Dark', - }; - - /// Header theme toggle: System -> Light -> Dark -> System. Light and - /// Dark take precedence over a `Sleuth.updateTheme` override, which - /// shows again on System. - void _cycleThemeMode() { - final next = switch (_ui.themeMode) { - SleuthThemeMode.system => SleuthThemeMode.light, - SleuthThemeMode.light => SleuthThemeMode.dark, - SleuthThemeMode.dark => SleuthThemeMode.system, - }; - _ui.themeMode = next; - _toast.show('Theme: ${_themeModeLabel(next)}'); - } - void _toggleSeverity(IssueSeverity severity) { if (!_ui.toggleSeverity(severity)) { _toast.show('Keep at least one severity', tone: OverlayToastTone.warning); @@ -1246,7 +1398,12 @@ class _FloatingIssuesCardState extends State } catch (e) { debugPrint('Sleuth: snapshot copy failed: $e'); if (mounted) { - _toast.show("Couldn't copy snapshot", tone: OverlayToastTone.warning); + _toast.show( + "Couldn't copy snapshot", + tone: OverlayToastTone.warning, + actionLabel: 'Try again', + onAction: _exportToClipboard, + ); } return; } @@ -1319,12 +1476,10 @@ class _FloatingIssuesCardState extends State // at 1.0x. final chromeScale = chromeScaleOf(context); final available = screenSize.height - safe.top - math.max(safe.bottom, 20); - // The card never gets shorter than its header, summary bar and - // footer. + // The card never gets shorter than its header, its status row and one + // 48 px row of the list. final chromeHeight = - _headerHeight + - _footerHeight + - _IssuesSummaryBar.hitHeightFor(chromeScale); + _headerHeight + _statusHeightFor(chromeScale) + _controlRowHeight; final minHeight = math.min( _minCardHeight * chromeScale, math.max(chromeHeight, available), @@ -1335,8 +1490,8 @@ class _FloatingIssuesCardState extends State final double cardHeight; if (_windowState == CardWindowState.maximized) { // Follows a rotation or window resize. Tracks the keyboard so the - // card shrinks above it, down to the header, the summary bar and - // the footer. + // card shrinks above it, down to the header, the status row and a + // row of the list. if (_maximizedFor != (screenSize, safe)) _fitMaximized(screenSize, safe); _cardHeight = _maximizedHeight(screenSize, safe, keyboardHeight); cardHeight = math.max(_cardHeight!, chromeHeight); @@ -1347,7 +1502,8 @@ class _FloatingIssuesCardState extends State maxAllowedHeight, ); } - final effectiveWidth = _effectiveWidth(screenSize); + _cachedMinWidth = _minWidthFor(screenSize, safe); + final effectiveWidth = _effectiveWidth(screenSize, safe); _cachedSafePadding = safe; _cachedEffectiveWidth = effectiveWidth; _cachedKeyboardHeight = keyboardHeight; @@ -1379,8 +1535,13 @@ class _FloatingIssuesCardState extends State if (layerDepth == 0) _listAtTop = _listScroll.keptOffset <= 0; final opened = layers.difference(_reportedLayers); _reportedLayers = layers; + _reportedMenuOpen = _menuOpen; widget.onLayersChanged?.call(); _afterLayersChanged(opened.isEmpty ? null : opened.last); + } else if (_menuOpen != _reportedMenuOpen) { + // The overlay blocks the app's semantics while the menu is open. + _reportedMenuOpen = _menuOpen; + widget.onLayersChanged?.call(); } return Stack( @@ -1390,6 +1551,7 @@ class _FloatingIssuesCardState extends State left: clamped.dx, top: clamped.dy, child: Stack( + key: _cardKey, clipBehavior: Clip.none, children: [ _buildCardBody( @@ -1400,6 +1562,7 @@ class _FloatingIssuesCardState extends State chromeScale, clamped, ), + if (_menuOpen && !isMinimized) _menuScrim(theme), if (!isMinimized) _buildResizeHandle( screenSize, @@ -1408,6 +1571,8 @@ class _FloatingIssuesCardState extends State maxAllowedHeight, theme, ), + if (_menuOpen && !isMinimized) + _menuPanel(effectiveWidth, cardHeight, theme, chromeScale), ], ), ), @@ -1570,40 +1735,18 @@ class _FloatingIssuesCardState extends State ), ); - /// Triggered by [_RebuildStatsBanner] when its frozen snapshot is - /// discarded by an automatic resume on route change. Shows a toast so - /// the user knows their pause was cleared and isn't surprised by - /// suddenly-live counts. - void _onRebuildPauseDiscarded() { - if (!mounted) return; - _toast.show('Route changed, pause cleared', tone: OverlayToastTone.warning); - } - - /// Called when the user taps `See all M →` in the expanded - /// [_RebuildStatsBanner] panel. When the panel is paused, [overrideCounts] - /// carries the panel's frozen snapshot and the drilldown opens against - /// THAT map (panel and drilldown agree on what the user is reading). - /// Otherwise reads the active [RouteSession] live and snapshots - /// [RouteSession.rebuildCountsByType] at tap time. - /// - /// If the session was cleared between the panel rendering and the tap - /// (pathological: route change mid-gesture), shows a "Session no longer - /// active" toast instead of pushing. - void _onSeeAllRebuildsTap([Map? overrideCounts]) { + /// Opens Rebuild stats on a snapshot of the active route's counts, taken + /// at tap time so later scans do not reorder the rows. When the session + /// ended before the tap (a route change mid-gesture) a toast says so + /// instead. + void _onSeeAllRebuildsTap() { final session = widget.controller.activeRouteSession; - // Choose the source of truth for the drilldown snapshot: - // * paused panel → frozen counts (what the user is currently reading) - // * live panel → fresh read of the session map - // If both are missing/empty, we have nothing to drill into. - final source = overrideCounts ?? session?.rebuildCountsByType; + final source = session?.rebuildCountsByType; if (source == null || source.isEmpty) { _toast.show('Session no longer active', tone: OverlayToastTone.warning); return; } setState(() { - // Defensive copy — snapshot semantics mean mutations to either the - // live session or the panel's frozen map must not reorder rows in - // the open drilldown. _rebuildStatsSnapshot = Map.of(source); _rebuildStatsRouteName = session?.routeName; _showRebuildStats = true; @@ -1618,13 +1761,14 @@ class _FloatingIssuesCardState extends State (_cardHeight ?? screenSize.height * 0.55).clamp(floor, ceiling); /// Shown width of the card: the stored width, at least the minimum. - double _effectiveWidth(Size screenSize) => _cardWidth - .clamp(_minCardWidth, math.max(_minCardWidth, screenSize.width)) - .toDouble(); + double _effectiveWidth(Size screenSize, EdgeInsets safe) { + final min = _minWidthFor(screenSize, safe); + return _cardWidth.clamp(min, math.max(min, screenSize.width)).toDouble(); + } /// Fits the whole card inside the usable area (the safe area above the - /// keyboard) before a move or resize is committed, so the footer and - /// the resize grip stay on screen. A resize ([keepTop]) keeps the top + /// keyboard) before a move or resize is committed, so the resize grip + /// stays on screen. A resize ([keepTop]) keeps the top /// edge and caps the height at the usable bottom, unless the minimum /// height does not fit below the top edge: then the card moves up and /// may take the whole usable height. A move keeps the height and moves @@ -1643,7 +1787,7 @@ class _FloatingIssuesCardState extends State final offset = _clampOffset( screenSize, safe, - _effectiveWidth(screenSize), + _effectiveWidth(screenSize, safe), _cachedKeyboardHeight, ); var shown = _cachedCardHeight; @@ -1705,18 +1849,19 @@ class _FloatingIssuesCardState extends State Offset position, ) { final isMinimized = _windowState == CardWindowState.minimized; - // The status row and banners scroll once they would take more than - // half of the space between header and footer and leave the list less - // than the summary bar plus about two collapsed cards (large text, a - // short card, an open FPS explainer). They never leave the list less - // than the summary bar plus one row; in a card squeezed below that - // (a keyboard over a maximized card) they give up all their space. - final middle = math.max(0.0, cardHeight - _headerHeight - _footerHeight); - final barHit = _IssuesSummaryBar.hitHeightFor(chromeScale); - final minList = barHit + 96; + // The banners scroll once they would take more than half of the space + // below the status row and leave the list less than about two + // collapsed cards (large text, a short card). They never leave the + // list less than one row; in a card squeezed below that (a keyboard + // over a maximized card) they give up all their space. + final middle = math.max( + 0.0, + cardHeight - _headerHeight - _statusHeightFor(chromeScale), + ); + const minList = 96.0; final bannersMaxHeight = math .max(middle * 0.5, middle - minList) - .clamp(0.0, math.max(0.0, middle - barHit - 48)) + .clamp(0.0, math.max(0.0, middle - 48)) .toDouble(); return ConstrainedBox( constraints: BoxConstraints( @@ -1737,64 +1882,167 @@ class _FloatingIssuesCardState extends State position, theme, ), - if (!isMinimized) ...[ - ConstrainedBox( - constraints: BoxConstraints(maxHeight: bannersMaxHeight), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _StatusRow(controller: widget.controller), - Divider(color: theme.border, height: 1), - _WarningBanners( - isDeepInstrumentationActive: - widget.controller.isDeepInstrumentationActive, - ), - if (widget.isDebugMode && - widget.controller.config.showDebugModeBanner && - !widget.controller.debugBannerDismissed) - _DebugModeBanner( - onDismiss: () => setState( - () => widget.controller.debugBannerDismissed = true, + if (!isMinimized) + Flexible( + // Under the menu's scrim the card's content is out of + // reach, for touch and for a screen reader. + child: ExcludeSemantics( + excluding: _menuOpen, + // The status row and the list share one pass over the + // reported issues. + child: ValueListenableBuilder>( + valueListenable: widget.controller.issuesNotifier, + builder: (context, issues, _) { + final model = _ListModel(issues, _ui); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _StatusRow( + controller: widget.controller, + chromeScale: chromeScale, + severityCounts: model.severityCounts, + enabledSeverities: _ui.severityFilter, + onToggleSeverity: _toggleSeverity, + firstChipKey: _firstChipKey, ), - ), - if (Sleuth.startupMetrics != null) - _StartupMetricsBanner( - onTap: () => - setState(() => _showStartupDetail = true), - ), - // Always-on inline rebuild-stats panel; see - // [_RebuildStatsBanner]. - _RebuildStatsBanner( - controller: widget.controller, - onTap: _onSeeAllRebuildsTap, - onPauseDiscarded: _onRebuildPauseDiscarded, - ), - ], + Divider(color: theme.cardDivider, height: 1), + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: bannersMaxHeight, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _WarningBanners( + isDeepInstrumentationActive: widget + .controller + .isDeepInstrumentationActive, + ), + if (widget.isDebugMode && + widget + .controller + .config + .showDebugModeBanner && + !widget.controller.debugBannerDismissed) + _DebugModeBanner( + onDismiss: () => setState( + () => + widget + .controller + .debugBannerDismissed = + true, + ), + ), + ], + ), + ), + ), + Flexible( + child: RepaintBoundary( + child: _buildIssuesList(model), + ), + ), + ], + ); + }, ), ), ), - Flexible( - child: RepaintBoundary(child: _buildIssuesList(chromeScale)), - ), - _CardFooter( - controller: widget.controller, - hiddenCount: _ui.hiddenKeys.length, - onShowHidden: () => setState(() => _showHidden = true), - onExport: _exportToClipboard, - onEncyclopedia: () => setState(() { - _detailStableId = null; - _showDetail = true; - }), - onGuide: () => setState(() => _showGuide = true), - ), - ], ], ), ), ); } + /// The menu's scrim over the card below the header. A tap on it closes + /// the menu and goes no further. + Widget _menuScrim(SleuthThemeData theme) => Positioned( + top: _headerHeight, + left: 0, + right: 0, + bottom: 0, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + excludeFromSemantics: true, + onTap: _closeMenu, + child: DecoratedBox( + decoration: BoxDecoration( + color: theme.dimOverlay, + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(theme.radiusCard), + ), + ), + ), + ), + ); + + /// The More menu panel, under the More button, at most the card height + /// below the header. + Widget _menuPanel( + double cardWidth, + double cardHeight, + SleuthThemeData theme, + double chromeScale, + ) { + final margin = theme.spacingMd; + final width = math.max( + 0.0, + math.min(236 * chromeScale, cardWidth - 2 * margin), + ); + // The More button's right edge: the header's right padding, close and + // the window controls (minimize and maximize, or restore). + final moreRight = + theme.spacingXs + + _controlRowHeight + + (_windowState == CardWindowState.normal ? 2 : 1) * 36; + final right = math.max( + margin, + math.min(moreRight, cardWidth - width - margin), + ); + final values = _menuValues!; + return Positioned( + top: _headerHeight - theme.spacingXs, + right: right, + width: width, + // Menu text grows like the header's, up to the chrome clamp; the + // panel width grows with it. + child: SleuthTextScaleClamp( + maxScaleFactor: kChromeMaxTextScale, + child: SleuthMoreMenu( + semanticsKey: _menuKey, + values: values, + themeMode: _ui.themeMode, + focusScope: _menuScope, + maxHeight: math.max(0.0, cardHeight - _headerHeight - margin), + onStartup: () => _openFromMenu(() => _showStartupDetail = true), + onRebuildStats: () { + _closeMenu(refocus: false); + _onSeeAllRebuildsTap(); + if (_showRebuildStats) { + _layerOpener = _moreOpener; + } else { + // No page opened (the route's session ended): focus goes + // back to More, under the toast that says why. + _focusMore(); + } + }, + onHiddenIssues: () => _openFromMenu(() => _showHidden = true), + onEncyclopedia: () => _openFromMenu(() { + _detailStableId = null; + _showDetail = true; + }), + onGuide: () => _openFromMenu(() => _showGuide = true), + onCopySnapshot: () { + _closeMenu(); + _exportToClipboard(); + }, + onThemeMode: (mode) => _ui.themeMode = mode, + ), + ), + ); + } + Widget _buildResizeHandle( Size screenSize, Offset clamped, @@ -1802,18 +2050,34 @@ class _FloatingIssuesCardState extends State double maxAllowedHeight, SleuthThemeData theme, ) { + final grip = CustomPaint( + painter: _CornerGripPainter(gripColor: theme.gripDots), + ); + // A maximized card's size follows the screen: it draws the dots in + // the same corner and takes no touches or actions there. + if (_windowState != CardWindowState.normal) { + return Positioned( + right: 0, + bottom: 0, + width: 48, + height: 48, + child: IgnorePointer(child: ExcludeSemantics(child: grip)), + ); + } + // The stored height keeps the unscaled minimum: the scaled floor // (large text) is display-only, so the card returns to its own height // at 1.0x. Growing starts from the shown height; shrinking from the // stored one while the floor holds the shown height up. + final minWidth = _cachedMinWidth; void resizeBy(double dw, double dh) { // A right-flush card grows leftwards ([_clampOffset] moves it), up // to the safe-area width minus [_clampOffset]'s 5 px margin, so it // never reaches under a landscape notch. _cardWidth = (_cardWidth + dw).clamp( - _minCardWidth, + minWidth, math.max( - _minCardWidth, + minWidth, screenSize.width - _cachedSafePadding.horizontal - 5, ), ); @@ -1835,10 +2099,14 @@ class _FloatingIssuesCardState extends State } // 48 x 48 hit box; the grip dots keep their corner position. Screen - // readers resize through the custom actions in 48 px steps. Resizing, - // by touch or by action, is offered only in the normal window state (a - // maximized card's size follows the screen). - final isNormal = _windowState == CardWindowState.normal; + // readers resize through the custom actions in 48 px steps. + final atMinWidth = _cachedEffectiveWidth <= minWidth; + final atMaxWidth = + _cachedEffectiveWidth >= + math.max( + minWidth, + screenSize.width - _cachedSafePadding.horizontal - 5, + ); return Positioned( right: 0, bottom: 0, @@ -1848,42 +2116,34 @@ class _FloatingIssuesCardState extends State container: true, label: 'Resize card', value: _sizeValue(_cachedEffectiveWidth, cardHeight), - customSemanticsActions: isNormal - ? { - const CustomSemanticsAction(label: 'Taller'): () => - resizeAndCommit(0, _a11yStep), - const CustomSemanticsAction(label: 'Shorter'): () => - resizeAndCommit(0, -_a11yStep), - const CustomSemanticsAction(label: 'Wider'): () => - resizeAndCommit(_a11yStep, 0), - const CustomSemanticsAction(label: 'Narrower'): () => - resizeAndCommit(-_a11yStep, 0), - } - : null, + customSemanticsActions: { + const CustomSemanticsAction(label: 'Taller'): () => + resizeAndCommit(0, _a11yStep), + const CustomSemanticsAction(label: 'Shorter'): () => + resizeAndCommit(0, -_a11yStep), + if (!atMaxWidth) + const CustomSemanticsAction(label: 'Wider'): () => + resizeAndCommit(_a11yStep, 0), + if (!atMinWidth) + const CustomSemanticsAction(label: 'Narrower'): () => + resizeAndCommit(-_a11yStep, 0), + }, child: MouseRegion( - cursor: isNormal - ? SystemMouseCursors.resizeDownRight - : MouseCursor.defer, + cursor: SystemMouseCursors.resizeDownRight, child: GestureDetector( behavior: HitTestBehavior.opaque, // The custom actions resize; pan scroll actions would not. excludeFromSemantics: true, // The grip follows the finger; the card is fitted on screen // when the drag ends. - onPanUpdate: isNormal - ? (details) => setState( - () => resizeBy(details.delta.dx, details.delta.dy), - ) - : null, - onPanEnd: isNormal - ? (_) { - setState(() => _fitToUsableArea(keepTop: true)); - _commitGeometry(); - } - : null, - child: CustomPaint( - painter: _CornerGripPainter(gripColor: theme.gripDots), - ), + onPanStart: (_) => _closeMenu(refocus: false), + onPanUpdate: (details) => + setState(() => resizeBy(details.delta.dx, details.delta.dy)), + onPanEnd: (_) { + setState(() => _fitToUsableArea(keepTop: true)); + _commitGeometry(); + }, + child: grip, ), ), ), @@ -1959,8 +2219,10 @@ class _FloatingIssuesCardState extends State : null, child: GestureDetector( excludeFromSemantics: true, - // While the finger is down the card keeps at least its title bar - // on screen; on release the whole card is fitted on screen. + // A drag closes the menu. While the finger is down the card keeps + // at least its title bar on screen; on release the whole card is + // fitted on screen. + onPanStart: movable ? (_) => _closeMenu(refocus: false) : null, onPanUpdate: movable ? (details) { setState(() { @@ -1992,23 +2254,13 @@ class _FloatingIssuesCardState extends State constraints: const BoxConstraints(minHeight: _controlRowHeight), child: Row( children: [ - Icon(Icons.pets, size: 14, color: theme.textPrimary), - SizedBox(width: theme.spacingXs), - // The title gives way first: it ellipsizes before any - // control loses its 48 px height. The header node carries - // the name. + // The title area gives way first (see [_headerTitle]); + // the controls keep their 48 px height. Expanded( - child: ExcludeSemantics( - child: Text( - 'Sleuth', - style: TextStyle( - color: theme.textPrimary, - fontSize: theme.fontBase, - fontWeight: FontWeight.bold, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + child: ValueListenableBuilder( + valueListenable: widget.controller.vmConnectedNotifier, + builder: (_, connected, _) => + _headerTitle(theme, connected), ), ), // Issue count badge (visible when minimized so user sees at a glance) @@ -2031,6 +2283,7 @@ class _FloatingIssuesCardState extends State ? theme.checkboxActive : theme.textTertiary, onTap: () { + _closeMenu(refocus: false); final newValue = !enabled; widget.controller.highlightEnabledNotifier.value = newValue; @@ -2041,19 +2294,7 @@ class _FloatingIssuesCardState extends State tooltip: enabled ? 'Hide overlay' : 'Show overlay', ), ), - // Theme toggle: System -> Light -> Dark (hidden when minimized). - if (!isMinimized) - _compactHeaderButton( - icon: switch (_ui.themeMode) { - SleuthThemeMode.system => Icons.brightness_auto, - SleuthThemeMode.light => Icons.light_mode, - SleuthThemeMode.dark => Icons.dark_mode, - }, - color: theme.textTertiary, - onTap: _cycleThemeMode, - tooltip: 'Toggle theme', - value: _themeModeLabel(_ui.themeMode), - ), + if (!isMinimized) _moreButton(theme), // Window controls. Hidden at narrow widths (<280px) so the // title keeps some room. if (showWindowControls && isNormal) @@ -2093,6 +2334,139 @@ class _FloatingIssuesCardState extends State ); } + /// Measured fit of the header title, cached for one set of inputs: a + /// drag rebuilds the header every frame. + ({double width, double scale, bool vm, bool debug})? _titleFitFor; + ({bool title, _RunLabelSize label})? _titleFit; + + /// The paw, "Sleuth" and the run label, stepped down to the width the + /// controls leave: all three, then without the "Sleuth" text (the paw + /// keeps the label), then the short run label, then its dot alone. + /// Widths are measured with the header's clamped text scale; the run + /// label never ellipsizes, and its semantics always carry the full text. + Widget _headerTitle(SleuthThemeData theme, bool vmConnected) { + final debug = widget.isDebugMode; + final mode = debug ? 'Debug' : 'Profile'; + final titleStyle = TextStyle( + color: theme.textPrimary, + fontSize: theme.fontBase, + fontWeight: FontWeight.bold, + ); + return LayoutBuilder( + builder: (context, constraints) { + final scaler = MediaQuery.textScalerOf(context); + final key = ( + width: constraints.maxWidth, + scale: scaler.scale(theme.fontBase), + vm: vmConnected, + debug: debug, + ); + var fit = _titleFit; + if (fit == null || _titleFitFor != key) { + // Text merges the inherited style (Material's letter spacing), + // so the measurement does too. + final inherited = DefaultTextStyle.of(context).style; + double measure(String text, TextStyle style) { + final painter = TextPainter( + text: TextSpan(text: text, style: inherited.merge(style)), + textDirection: TextDirection.ltr, + textScaler: scaler, + maxLines: 1, + )..layout(); + final width = painter.width; + painter.dispose(); + return width; + } + + final gap = theme.spacingXs; + const paw = 14.0; + final labelStyle = _RunLabel.textStyle(theme); + final chrome = _RunLabel.chromeWidth(theme); + final full = + chrome + measure(_RunLabel.text(mode, vmConnected), labelStyle); + final short = chrome + measure(mode, labelStyle); + final title = measure('Sleuth', titleStyle) + gap; + final room = constraints.maxWidth - paw - gap; + fit = title + full <= room + ? (title: true, label: _RunLabelSize.full) + : full <= room + ? (title: false, label: _RunLabelSize.full) + : short <= room + ? (title: false, label: _RunLabelSize.short) + : (title: false, label: _RunLabelSize.dot); + _titleFit = fit; + _titleFitFor = key; + } + return Row( + children: [ + Icon(Icons.pets, size: 14, color: theme.textPrimary), + SizedBox(width: theme.spacingXs), + if (fit.title) ...[ + // The header node carries the name. + ExcludeSemantics( + child: Text( + 'Sleuth', + style: titleStyle, + maxLines: 1, + softWrap: false, + ), + ), + SizedBox(width: theme.spacingXs), + ], + _RunLabel(mode: mode, vmConnected: vmConnected, size: fit.label), + ], + ); + }, + ); + } + + /// The header's More button. It opens and closes the menu, and keyboard + /// focus returns to it when the menu closes. + Widget _moreButton(SleuthThemeData theme) { + final radius = BorderRadius.circular(theme.radiusLg); + return Semantics( + key: _moreKey, + label: 'More', + button: true, + expanded: _menuOpen, + onTap: _toggleMenu, + excludeSemantics: true, + child: SleuthFocusInkWell( + focusNode: _moreFocus, + onTap: _toggleMenu, + borderRadius: radius, + builder: (context, showFocus) => SizedBox( + width: 36, + height: _controlRowHeight, + child: Center( + child: SleuthFocusOutline( + show: showFocus, + color: theme.checkboxActive, + borderRadius: radius, + child: DecoratedBox( + decoration: BoxDecoration( + color: _menuOpen + ? theme.checkboxActive.withValues(alpha: 0.22) + : null, + borderRadius: radius, + ), + child: SizedBox( + width: 32, + height: 32, + child: Icon( + Icons.more_horiz, + size: 16, + color: theme.textTertiary, + ), + ), + ), + ), + ), + ), + ), + ); + } + /// Issue count shown in the minimized header; nothing without issues. Widget _minimizedCountBadge( List visible, @@ -2186,15 +2560,15 @@ class _FloatingIssuesCardState extends State // ─── Issues List ───────────────────────────────────────────────────── - Widget _buildIssuesList(double chromeScale) { + Widget _buildIssuesList(_ListModel model) { // The list rebuilds only when the issue set changes. Each card's // recurrence badge re-reads `recurrenceTrends` on the scan pulse by // itself, so a tick that leaves the issues unchanged rebuilds badges, // not cards. - return ValueListenableBuilder>( - valueListenable: widget.controller.issuesNotifier, - builder: (context, issues, _) { + { + { final theme = SleuthTheme.of(context); + final issues = model.issues; if (issues.isEmpty) { // The next list starts at the top. _listScroll.dropKeptOffset(); @@ -2209,90 +2583,29 @@ class _FloatingIssuesCardState extends State ); } - // Severity filter, then collapse, then hide — the same list + // Severity filter, then collapse, then hide: the same list // `_pruneStaleState` keys against. See [applyOverlayFilters]. - final ui = _ui; - final visibleIssues = ui.visibleIssues(issues); - - // Lists behind the summary chips and the "Showing X of Y" line. - final allSeverities = {...IssueSeverity.values}; - final unfiltered = applyOverlayFilters( - issues, - severities: allSeverities, - hiddenKeys: ui.hiddenKeys, - ); - final unhidden = applyOverlayFilters( - issues, - severities: allSeverities, - hiddenKeys: const {}, - ); - // `unhidden` is the default-state list (every severity, nothing - // hidden), so its length is the card total. Stale hidden keys - // that match nothing leave the count unchanged and do not narrow. - final totalCards = unhidden.length; - final isNarrowed = visibleIssues.length < totalCards; - - // A chip counts the cards its severity shows under the current - // filter. Turning a severity off can surface an effect that was - // collapsed under one of its cards, so a disabled severity is - // counted with itself added to the filter. - final severityCounts = {}; - for (final issue in visibleIssues) { - severityCounts.update( - issue.severity, - (n) => n + 1, - ifAbsent: () => 1, - ); - } - for (final severity in IssueSeverity.values) { - if (ui.severityFilter.contains(severity)) continue; - final withSeverity = applyOverlayFilters( - issues, - severities: {...ui.severityFilter, severity}, - hiddenKeys: ui.hiddenKeys, - ); - severityCounts[severity] = withSeverity - .where((i) => i.severity == severity) - .length; - } - - final summary = _IssuesSummaryBar( - chromeScale: chromeScale, - issues: visibleIssues, - severityCounts: severityCounts, - enabledSeverities: ui.severityFilter, - shownOfTotal: isNarrowed - ? (shown: visibleIssues.length, total: totalCards) - : null, - onToggleSeverity: _toggleSeverity, - ); + final visibleIssues = model.visible; if (visibleIssues.isEmpty) { // The next list starts at the top. _listAtTop = true; _listScroll.dropKeptOffset(); - final allHidden = unfiltered.isEmpty; - return _IssuesSummaryBar.above( - chromeScale: chromeScale, - summary: summary, - body: _EmptyListMessage( - message: allHidden - ? 'All ${unhidden.length} ' - '${unhidden.length == 1 ? 'issue' : 'issues'} hidden' - : 'No issues match the severity filter', - actionLabel: allHidden ? 'Show hidden' : 'Reset', - onAction: allHidden - ? () => setState(() => _showHidden = true) - : ui.resetSeverityFilter, - ), + final allHidden = model.unfiltered.isEmpty; + return _EmptyListMessage( + message: allHidden + ? 'All ${model.unhidden.length} ' + '${model.unhidden.length == 1 ? 'issue' : 'issues'} hidden' + : 'No issues match the severity filter', + actionLabel: allHidden ? 'Show hidden' : 'Reset', + onAction: allHidden + ? () => setState(() => _showHidden = true) + : _showAllSeverities, ); } - // Apply the freeze zone AFTER the summary bar reads the flow - // ordering. Freezing is a render-order concern only — counts in - // the summary bar must not change based on which cards are - // currently expanded. Always feed the flow (pre-freeze) list - // into `_IssuesSummaryBar`. + // The freeze zone only orders the cards; the status row counts + // the flow list, whatever is expanded. // // The held order runs first, so the freeze snapshot captures // what the user saw and new cards enter the flow below it. @@ -2341,665 +2654,576 @@ class _FloatingIssuesCardState extends State for (var i = 0; i < listKeys.length; i++) listKeys[i]: i, }; - return _IssuesSummaryBar.above( - chromeScale: chromeScale, - summary: summary, - body: ValueListenableBuilder( - valueListenable: widget.controller.selectedHighlightNotifier, - builder: (_, selectedHighlight, _) => Listener( - // Touches on the list hold back a pending reorder. - behavior: HitTestBehavior.translucent, - onPointerDown: _onListPointerDown, - onPointerMove: (_) => _onListActivity(), - onPointerUp: _onListPointerUp, - onPointerCancel: _onListPointerUp, - onPointerSignal: (_) => _onListActivity(), - onPointerPanZoomStart: (_) => _onListActivity(), - onPointerPanZoomUpdate: (_) => _onListActivity(), - // Scrolls that reach no Listener (a screen reader's scroll - // action) also hold it back, and report the offset. - child: NotificationListener( - onNotification: _onListScroll, - child: ListView.builder( - controller: _listScroll, - padding: EdgeInsets.all(theme.spacingSm), - itemCount: orderedIssues.length, - // Keyed-reorder remount fix: without a - // `findChildIndexCallback`, `SliverChildBuilderDelegate` - // cannot locate a keyed child whose index has shifted - // between builds, so Flutter destroys the Element and - // builds a fresh one — which resets `_IssueCardState` - // (loses expansion, scroll, and all local UI state). - // This hits any issue whose rank position moves when - // the ranker reorders the list. Cards are already - // `ValueKey`-stamped with `listKeyFor`; this callback - // just tells the sliver where each key landed. - // - // Looks up `orderedIndexByKey` (the POST-pin map) so - // the sliver locates keyed children at their rendered - // positions. Using the pre-pin list here would remount - // every pinned card on the first render after pin - // application, which resets `_IssueCardState` — the - // very bug the `ValueKey` + findChildIndexCallback - // pair exists to prevent. - findChildIndexCallback: (Key key) { - if (key is! ValueKey) return null; - return orderedIndexByKey[key.value]; - }, - itemBuilder: (_, index) { - final issue = orderedIssues[index]; - final locatable = _isLocatableIssue(issue); - final issueKey = listKeys[index]; - final isHighlighted = - selectedHighlight != null && - locatable && - _selectedIssueId == issueKey; - - // Look up downstream issue objects for root issues. - // Uses the precomputed stableId→issue map (O(1) per - // lookup) so this resolution does not blow up to - // O(n²) on tall overlays. - List? downstream; - if (issue.downstreamIds != null && - issue.downstreamIds!.isNotEmpty) { - downstream = []; - for (final downId in issue.downstreamIds!) { - final found = stableIdToIssue[downId]; - if (found != null) downstream.add(found); - } + final list = ValueListenableBuilder( + valueListenable: widget.controller.selectedHighlightNotifier, + builder: (_, selectedHighlight, _) => Listener( + // Touches on the list hold back a pending reorder. + behavior: HitTestBehavior.translucent, + onPointerDown: _onListPointerDown, + onPointerMove: (_) => _onListActivity(), + onPointerUp: _onListPointerUp, + onPointerCancel: _onListPointerUp, + onPointerSignal: (_) => _onListActivity(), + onPointerPanZoomStart: (_) => _onListActivity(), + onPointerPanZoomUpdate: (_) => _onListActivity(), + // Scrolls that reach no Listener (a screen reader's scroll + // action) also hold it back, and report the offset. + child: NotificationListener( + onNotification: _onListScroll, + child: ListView.builder( + controller: _listScroll, + // The grip sits over the bottom-right corner; the last + // card scrolls clear of it. + padding: EdgeInsets.fromLTRB( + theme.spacingSm, + theme.spacingSm, + theme.spacingSm, + 48, + ), + itemCount: orderedIssues.length, + // Keyed-reorder remount fix: without a + // `findChildIndexCallback`, `SliverChildBuilderDelegate` + // cannot locate a keyed child whose index has shifted + // between builds, so Flutter destroys the Element and + // builds a fresh one — which resets `_IssueCardState` + // (loses expansion, scroll, and all local UI state). + // This hits any issue whose rank position moves when + // the ranker reorders the list. Cards are already + // `ValueKey`-stamped with `listKeyFor`; this callback + // just tells the sliver where each key landed. + // + // Looks up `orderedIndexByKey` (the POST-pin map) so + // the sliver locates keyed children at their rendered + // positions. Using the pre-pin list here would remount + // every pinned card on the first render after pin + // application, which resets `_IssueCardState` — the + // very bug the `ValueKey` + findChildIndexCallback + // pair exists to prevent. + findChildIndexCallback: (Key key) { + if (key is! ValueKey) return null; + return orderedIndexByKey[key.value]; + }, + itemBuilder: (_, index) { + final issue = orderedIssues[index]; + final locatable = _isLocatableIssue(issue); + final issueKey = listKeys[index]; + final isHighlighted = + selectedHighlight != null && + locatable && + _selectedIssueId == issueKey; + + // Look up downstream issue objects for root issues. + // Uses the precomputed stableId→issue map (O(1) per + // lookup) so this resolution does not blow up to + // O(n²) on tall overlays. + List? downstream; + if (issue.downstreamIds != null && + issue.downstreamIds!.isNotEmpty) { + downstream = []; + for (final downId in issue.downstreamIds!) { + final found = stableIdToIssue[downId]; + if (found != null) downstream.add(found); } + } - // Resolve parent issues for the multi-parent "Caused - // by" badge. parentIssues is null when no annotation - // exists or when every parent is suppressed by the - // ranker. Suppressed-but-annotated parents surface - // as a count for the IssueCard's "(+N not shown)" - // annotation so a partial parent list does not look - // complete. - List? parents; - var suppressedParentCount = 0; - final parentIds = issue.rootCauseIds; - if (parentIds != null && parentIds.isNotEmpty) { - parents = []; - for (final parentId in parentIds) { - final found = stableIdToIssue[parentId]; - if (found != null) { - parents.add(found); - } else { - suppressedParentCount++; - } + // Resolve parent issues for the multi-parent "Caused + // by" badge. parentIssues is null when no annotation + // exists or when every parent is suppressed by the + // ranker. Suppressed-but-annotated parents surface + // as a count for the IssueCard's "(+N not shown)" + // annotation so a partial parent list does not look + // complete. + List? parents; + var suppressedParentCount = 0; + final parentIds = issue.rootCauseIds; + if (parentIds != null && parentIds.isNotEmpty) { + parents = []; + for (final parentId in parentIds) { + final found = stableIdToIssue[parentId]; + if (found != null) { + parents.add(found); + } else { + suppressedParentCount++; } - if (parents.isEmpty) parents = null; } + if (parents.isEmpty) parents = null; + } - // Capture the build-time `index` into a local so the - // `onExpandedChanged` closure closes over a - // deterministic value instead of whatever `index` - // would be at callback-time (which could be stale - // if a scan tick fired between build and tap). - final capturedIndex = index; - - // Capture the build-time visibleIssues reference so - // the snapshot taken on 0→1 expand reflects what the - // user actually saw, NOT a newer value that may have - // been published to `issuesNotifier` between the - // frame commit and the tap arriving. Defensive copy - // is made inside the callback so the snapshot - // outlives this build closure without being aliased - // to the live list. - final capturedVisibleIssues = heldIssues; - - // The list `capturedIndex` indexes. An expansion below the - // frozen zone re-captures the snapshot from it. - final capturedOrdered = orderedIssues; - - return IssueCard( - key: ValueKey(listKeys[index]), - issue: issue, - recurrenceTrendOf: () => - widget.controller.recurrenceTrends[issue.stableId ?? - issue.title], - scanTick: widget.controller.scanTickNotifier, - deepInstrumentationActive: - widget.controller.isDeepInstrumentationActive, - initiallyExpanded: _expandedIndices.containsKey(issueKey), - collapseEpoch: _collapseEpoch, - onExpandedChanged: (expanded) { - setState(() { - if (expanded) { - // 0→1 transition: capture snapshot before - // recording the expand entry so the class - // invariant (snapshot != null ↔ map not - // empty) holds at every observable state. - if (_expandedIndices.isEmpty) { - _orderSnapshot = List.of( - capturedVisibleIssues, - ); - } else if (capturedIndex > - _expandedIndices.values.reduce(math.max)) { - // The zone grows past the snapshot's frozen - // slice, whose tail may no longer match the rows - // on screen. Re-capture what the user sees so - // the new index points into the list it came - // from. - final snapshot = List.of( - capturedOrdered, - ); - _repointExpansions(snapshot); - _orderSnapshot = snapshot; - } - _expandedIndices[issueKey] = capturedIndex; - } else { - _expandedIndices.remove(issueKey); - // 1→0 transition: release the snapshot so - // the next expand captures a fresh one from - // whatever the ranker currently shows. The - // order on screen becomes the held order, so - // nothing moves under the finger; a pending - // change waits for the next quiet period. - if (_expandedIndices.isEmpty) { - _orderSnapshot = null; - _heldKeys = [ - ...{ - for (final i in capturedOrdered) - listKeyFor(i), - }, - ]; - } + // Capture the build-time `index` into a local so the + // `onExpandedChanged` closure closes over a + // deterministic value instead of whatever `index` + // would be at callback-time (which could be stale + // if a scan tick fired between build and tap). + final capturedIndex = index; + + // Capture the build-time visibleIssues reference so + // the snapshot taken on 0→1 expand reflects what the + // user actually saw, NOT a newer value that may have + // been published to `issuesNotifier` between the + // frame commit and the tap arriving. Defensive copy + // is made inside the callback so the snapshot + // outlives this build closure without being aliased + // to the live list. + final capturedVisibleIssues = heldIssues; + + // The list `capturedIndex` indexes. An expansion below the + // frozen zone re-captures the snapshot from it. + final capturedOrdered = orderedIssues; + + return IssueCard( + key: ValueKey(listKeys[index]), + issue: issue, + recurrenceTrendOf: () => + widget.controller.recurrenceTrends[issue.stableId ?? + issue.title], + scanTick: widget.controller.scanTickNotifier, + deepInstrumentationActive: + widget.controller.isDeepInstrumentationActive, + initiallyExpanded: _expandedIndices.containsKey(issueKey), + collapseEpoch: _collapseEpoch, + onExpandedChanged: (expanded) { + setState(() { + if (expanded) { + // 0→1 transition: capture snapshot before + // recording the expand entry so the class + // invariant (snapshot != null ↔ map not + // empty) holds at every observable state. + if (_expandedIndices.isEmpty) { + _orderSnapshot = List.of( + capturedVisibleIssues, + ); + } else if (capturedIndex > + _expandedIndices.values.reduce(math.max)) { + // The zone grows past the snapshot's frozen + // slice, whose tail may no longer match the rows + // on screen. Re-capture what the user sees so + // the new index points into the list it came + // from. + final snapshot = List.of( + capturedOrdered, + ); + _repointExpansions(snapshot); + _orderSnapshot = snapshot; } - }); - }, - locatable: locatable, - highlighted: isHighlighted, - onHighlightChanged: locatable - ? (checked) => - _onHighlightChanged(checked, issueKey, issue) - : null, - jankCorrelated: _cachedJankKeys.contains( - issue.stableId ?? issue.title, - ), - jankFlash: false, - downstreamIssues: downstream, - parentIssues: parents, - suppressedParentCount: suppressedParentCount, - onLearnMore: - IssueExplanationBuilder.explain(issue.stableId) != - null - ? () => setState(() { - _detailStableId = issue.stableId; - _detailContextIssue = issue; - _showDetail = true; - _layerOpener = issueKey; - }) - : null, - onAskAi: widget.controller.config.aiChat != null - ? () => setState(() { - _chatIssueKey = _chatKeyFor(issue); - _chatIssue = issue; - _showAiChat = true; - _layerOpener = issueKey; - }) - : null, - onCopy: () => _copyIssue(issue), - onHide: () => _hideIssue(issue), - isNew: _newKeyTimers.containsKey(listKeyFor(issue)), - ); - }, - ), + _expandedIndices[issueKey] = capturedIndex; + } else { + _expandedIndices.remove(issueKey); + // 1→0 transition: release the snapshot so + // the next expand captures a fresh one from + // whatever the ranker currently shows. The + // order on screen becomes the held order, so + // nothing moves under the finger; a pending + // change waits for the next quiet period. + if (_expandedIndices.isEmpty) { + _orderSnapshot = null; + _heldKeys = [ + ...{ + for (final i in capturedOrdered) listKeyFor(i), + }, + ]; + } + } + }); + }, + locatable: locatable, + highlighted: isHighlighted, + onHighlightChanged: locatable + ? (checked) => + _onHighlightChanged(checked, issueKey, issue) + : null, + jankCorrelated: _cachedJankKeys.contains( + issue.stableId ?? issue.title, + ), + jankFlash: false, + downstreamIssues: downstream, + parentIssues: parents, + suppressedParentCount: suppressedParentCount, + onLearnMore: + IssueExplanationBuilder.explain(issue.stableId) != null + ? () => setState(() { + _detailStableId = issue.stableId; + _detailContextIssue = issue; + _showDetail = true; + _layerOpener = issueKey; + }) + : null, + onAskAi: widget.controller.config.aiChat != null + ? () => setState(() { + _chatIssueKey = _chatKeyFor(issue); + _chatIssue = issue; + _showAiChat = true; + _layerOpener = issueKey; + }) + : null, + onCopy: () => _copyIssue(issue), + onHide: () => _hideIssue(issue), + isNew: _newKeyTimers.containsKey(listKeyFor(issue)), + ); + }, ), ), ), ); - }, - ); + if (!model.narrowed) return list; + return Column( + children: [ + _ShowingRow( + shown: visibleIssues.length, + total: model.unfiltered.length, + onShowAll: _showAllSeverities, + ), + Expanded(child: list), + ], + ); + } + } } } // ─── Status Row ───────────────────────────────────────────────────────── -enum _LineSlot { lead, trail } - -/// [lead] at the start and [trail] at the end of one line, vertically -/// centred; when they do not fit side by side, [trail] moves below -/// [lead] and stays at the end. -class _LeadTrailLine - extends SlottedMultiChildRenderObjectWidget<_LineSlot, RenderBox> { - const _LeadTrailLine({ - required this.lead, - required this.trail, - required this.gap, - }); - - final Widget lead; - final Widget trail; - - /// Smallest horizontal gap between [lead] and [trail] on one line. - final double gap; - - @override - Iterable<_LineSlot> get slots => _LineSlot.values; - - @override - Widget? childForSlot(_LineSlot slot) => switch (slot) { - _LineSlot.lead => lead, - _LineSlot.trail => trail, - }; - - @override - _RenderLeadTrailLine createRenderObject(BuildContext context) => - _RenderLeadTrailLine(gap); - - @override - void updateRenderObject( - BuildContext context, - _RenderLeadTrailLine renderObject, - ) { - renderObject.gap = gap; +/// The cards behind the status row and the list, worked out once per +/// issue change: severity filter, then collapse, then hide. +class _ListModel { + _ListModel(this.issues, OverlayUiState ui) { + visible = ui.visibleIssues(issues); + final allSeverities = {...IssueSeverity.values}; + unfiltered = applyOverlayFilters( + issues, + severities: allSeverities, + hiddenKeys: ui.hiddenKeys, + ); + // The default-state list (every severity, nothing hidden). Stale + // hidden keys that match nothing leave it unchanged. + unhidden = applyOverlayFilters( + issues, + severities: allSeverities, + hiddenKeys: const {}, + ); + // A chip counts the cards its severity shows under the current + // filter. Turning a severity off can surface an effect that was + // collapsed under one of its cards, so a disabled severity is counted + // with itself added to the filter. + for (final issue in visible) { + severityCounts.update(issue.severity, (n) => n + 1, ifAbsent: () => 1); + } + for (final severity in IssueSeverity.values) { + if (ui.severityFilter.contains(severity)) continue; + final withSeverity = applyOverlayFilters( + issues, + severities: {...ui.severityFilter, severity}, + hiddenKeys: ui.hiddenKeys, + ); + severityCounts[severity] = withSeverity + .where((i) => i.severity == severity) + .length; + } } -} -class _RenderLeadTrailLine extends RenderBox - with SlottedContainerRenderObjectMixin<_LineSlot, RenderBox> { - _RenderLeadTrailLine(this._gap); + /// The reported issues. + final List issues; - double _gap; - set gap(double value) { - if (value == _gap) return; - _gap = value; - markNeedsLayout(); - } + /// The cards the list shows. + late final List visible; - RenderBox? get _lead => childForSlot(_LineSlot.lead); - RenderBox? get _trail => childForSlot(_LineSlot.trail); + /// The cards with every severity on and hidden cards left out. + late final List unfiltered; - Size _layout( - BoxConstraints constraints, - ChildLayouter layoutChild, { - bool position = false, - }) { - final lead = _lead; - final trail = _trail; - final loose = constraints.loosen(); - final leadSize = lead == null ? Size.zero : layoutChild(lead, loose); - final trailSize = trail == null ? Size.zero : layoutChild(trail, loose); - final oneLineWidth = leadSize.width + _gap + trailSize.width; - final width = constraints.hasBoundedWidth - ? constraints.maxWidth - : oneLineWidth; - final oneLine = oneLineWidth <= width; - final height = oneLine - ? math.max(leadSize.height, trailSize.height) - : leadSize.height + trailSize.height; - if (position) { - final trailX = math.max(0.0, width - trailSize.width); - if (lead != null) { - (lead.parentData! as BoxParentData).offset = Offset( - 0, - oneLine ? (height - leadSize.height) / 2 : 0, - ); - } - if (trail != null) { - (trail.parentData! as BoxParentData).offset = Offset( - trailX, - oneLine ? (height - trailSize.height) / 2 : leadSize.height, - ); - } - } - return constraints.constrain(Size(width, height)); - } + /// The cards with every severity on and nothing hidden. + late final List unhidden; - @override - Size computeDryLayout(covariant BoxConstraints constraints) => - _layout(constraints, ChildLayoutHelper.dryLayoutChild); + final Map severityCounts = {}; - @override - void performLayout() { - size = _layout(constraints, ChildLayoutHelper.layoutChild, position: true); - } + /// A severity filter leaves cards out. Hidden cards are counted in the + /// More menu, not here. + bool get narrowed => visible.length < unfiltered.length; +} - @override - double computeMinIntrinsicWidth(double height) => math.max( - _lead?.getMinIntrinsicWidth(height) ?? 0, - _trail?.getMinIntrinsicWidth(height) ?? 0, - ); +/// The row under the header: the severity chips, which are the headline +/// and the filter, and the frame rate at the end. +class _StatusRow extends StatelessWidget { + const _StatusRow({ + required this.controller, + required this.chromeScale, + required this.severityCounts, + required this.enabledSeverities, + required this.onToggleSeverity, + required this.firstChipKey, + }); - @override - double computeMaxIntrinsicWidth(double height) => - (_lead?.getMaxIntrinsicWidth(height) ?? 0) + - _gap + - (_trail?.getMaxIntrinsicWidth(height) ?? 0); + final SleuthController controller; - double _intrinsicHeight(double width, bool max) { - double h(RenderBox? box) => box == null - ? 0 - : max - ? box.getMaxIntrinsicHeight(width) - : box.getMinIntrinsicHeight(width); - final fits = computeMaxIntrinsicWidth(double.infinity) <= width; - return fits ? math.max(h(_lead), h(_trail)) : h(_lead) + h(_trail); - } + /// Chrome text scale (1.0 to 1.3); the row's minimum height grows with + /// it. + final double chromeScale; - @override - double computeMinIntrinsicHeight(double width) => - _intrinsicHeight(width, false); + final Map severityCounts; + final Set enabledSeverities; + final ValueChanged onToggleSeverity; - @override - double computeMaxIntrinsicHeight(double width) => - _intrinsicHeight(width, true); + /// Goes on whichever chip comes first. + final GlobalKey firstChipKey; - @override - void paint(PaintingContext context, Offset offset) { - for (final child in children) { - final parentData = child.parentData! as BoxParentData; - context.paintChild(child, offset + parentData.offset); - } - } - - @override - bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { - for (final child in children) { - final parentData = child.parentData! as BoxParentData; - final hit = result.addWithPaintOffset( - offset: parentData.offset, - position: position, - hitTest: (result, transformed) => - child.hitTest(result, position: transformed), - ); - if (hit) return true; - } - return false; - } -} - -class _StatusRow extends StatefulWidget { - const _StatusRow({required this.controller}); - - final SleuthController controller; - - @override - State<_StatusRow> createState() => _StatusRowState(); -} - -class _StatusRowState extends State<_StatusRow> { - /// Minimum frames in the buffer before the primary numeral shows. - /// Shown as `—` while the buffer warms up so the first tick does not - /// flash a red 0. - static const int _warmupFrameCount = 3; - - /// True when the user has tapped the info icon — reveals the Actual / - /// Throughput FPS detail row and short explainer. - bool _infoExpanded = false; - - SleuthController get controller => widget.controller; + static const _order = [ + IssueSeverity.critical, + IssueSeverity.warning, + IssueSeverity.ok, + ]; @override Widget build(BuildContext context) { final theme = SleuthTheme.of(context); - // The FPS group and the VM+/FRAME and DBG badges wrap; the issue count - // stays at the right edge, below them when it does not fit beside. + // A disabled severity keeps its chip so it can be turned back on, even + // when it has no cards now. + final shown = [ + for (final severity in _order) + if ((severityCounts[severity] ?? 0) > 0 || + !enabledSeverities.contains(severity)) + severity, + ]; return SleuthTextScaleClamp( maxScaleFactor: kChromeMaxTextScale, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: theme.spacingLg), - child: _LeadTrailLine( - gap: theme.spacingXs, - lead: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - spacing: theme.spacingXs, - children: [ - _fpsGroup(theme), - _modeBadge(theme), - if (kDebugMode && controller.isDebugCallbacksActive) - _badge(theme, 'DBG', theme.badgeDbgBg, theme.badgeDbgText), - ], - ), - trail: _issueCount(theme), - ), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: _FloatingIssuesCardState._controlRowHeight * chromeScale, + ), + child: Padding( + padding: EdgeInsets.only( + left: theme.spacingSm, + right: theme.spacingLg, ), - if (_infoExpanded) ...[ - Padding( - padding: EdgeInsets.fromLTRB( - theme.spacingLg, - 0, - theme.spacingLg, - theme.spacingXs, - ), - child: Text( - 'TPUT (primary) estimates capacity from frame latency.\n' - 'ACTUAL counts presented frames per second, so it is low when idle.', - style: TextStyle( - color: theme.textTertiary, - fontSize: theme.fontXs, - height: 1.4, + // One line: the chips scroll sideways when they do not fit, so + // the row keeps its height in a squeezed card and the frame rate + // stays at the end. + child: Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final (i, severity) in shown.indexed) + Padding( + padding: EdgeInsets.only(right: theme.spacingXxs), + child: _SeverityChip( + key: i == 0 ? firstChipKey : null, + severity: severity, + count: severityCounts[severity] ?? 0, + selected: enabledSeverities.contains(severity), + onTap: () => onToggleSeverity(severity), + ), + ), + ], + ), ), ), - ), - _ThroughputDetailRow(controller: controller), - ], - ], + SizedBox(width: theme.spacingSm), + _FpsText(controller: controller), + ], + ), + ), ), ); } +} + +/// The frame rate at the end of the status row: the throughput estimate, +/// which stays steady on an idle screen, or a dash while the first frames +/// arrive. +class _FpsText extends StatelessWidget { + const _FpsText({required this.controller}); - // Primary numeral shows throughputFps (latency-derived) so idle screens - // read smooth — actualFps counts presented frames and drops to low - // values when Flutter is not repainting. True device rate is still - // exposed in the expanded detail row (ACTUAL cell) and the snapshot - // export. - Widget _fpsGroup(SleuthThemeData theme) { + final SleuthController controller; + + /// Frames in the buffer before a rate shows. + static const int _warmupFrameCount = 3; + + @override + Widget build(BuildContext context) { + final theme = SleuthTheme.of(context); return ValueListenableBuilder( valueListenable: controller.frameStatsNotifier, builder: (_, buffer, _) { final target = controller.config.fpsTarget; - final isWarming = buffer.length < _warmupFrameCount; - final fps = buffer.throughputFps.clamp(0.0, target.toDouble()); - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - isWarming ? '—' : fps.toStringAsFixed(0), - style: TextStyle( - color: isWarming - ? theme.textTertiary - : theme.fpsTextColor(fps, target: target), - fontSize: theme.fontXxl, - fontWeight: FontWeight.bold, - ), - ), - SizedBox(width: theme.spacingXxs), - Text( - 'FPS', - style: TextStyle( - color: theme.textTertiary, - fontSize: theme.fontSm, - ), - ), - Semantics( - label: _infoExpanded - ? 'Hide FPS explainer' - : 'Show FPS explainer', - button: true, - expanded: _infoExpanded, - child: GestureDetector( - onTap: () => setState(() => _infoExpanded = !_infoExpanded), - behavior: HitTestBehavior.opaque, - child: SizedBox( - width: 48, - height: 48, - child: Center( - child: Icon( - Icons.info_outline, - size: 14, - color: theme.textQuaternary, - ), - ), - ), - ), + final warming = buffer.length < _warmupFrameCount; + final fps = buffer.throughputFps + .clamp(0.0, target.toDouble()) + .toStringAsFixed(0); + return Semantics( + label: warming ? 'Frame rate pending' : '$fps frames per second', + excludeSemantics: true, + child: Text( + warming ? '— fps' : '$fps fps', + style: TextStyle( + color: theme.textTertiary, + fontSize: theme.fontBase, + fontFeatures: const [FontFeature.tabularFigures()], ), - ], + ), ); }, ); } +} - /// VM+ when the VM service is connected, FRAME otherwise. - Widget _modeBadge(SleuthThemeData theme) { - return ValueListenableBuilder( - valueListenable: controller.vmConnectedNotifier, - builder: (_, connected, _) => _badge( - theme, - connected ? 'VM+' : 'FRAME', - connected ? theme.badgeVmBg : theme.badgeFrameBg, - connected ? theme.badgeVmText : theme.badgeFrameText, - ), - ); - } +/// How much of the run label the header has room for. +enum _RunLabelSize { + /// "Profile · VM". + full, - Widget _badge(SleuthThemeData theme, String label, Color bg, Color fg) { - return DecoratedBox( - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(theme.radiusLg), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), - child: Text( - label, - style: TextStyle( - color: fg, - fontSize: theme.fontXxs, - fontWeight: FontWeight.bold, - ), - ), - ), - ); - } + /// "Profile". + short, - /// Issue count with a severity dot. - Widget _issueCount(SleuthThemeData theme) { - return ValueListenableBuilder>( - valueListenable: controller.issuesNotifier, - builder: (_, issues, _) { - if (issues.isEmpty) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.check_circle, color: theme.severityOk, size: 14), - SizedBox(width: theme.spacingXs), - Text( - '0 issues', - style: TextStyle( - color: theme.severityOkText, - fontSize: theme.fontMd, - ), - ), - ], - ); - } - final hasCritical = issues.any( - (i) => i.severity == IssueSeverity.critical, - ); - final severityColor = hasCritical - ? theme.severityCritical - : theme.severityWarning; - final severityText = hasCritical - ? theme.severityCriticalText - : theme.severityWarningText; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - DecoratedBox( - decoration: BoxDecoration( - color: severityColor, - shape: BoxShape.circle, - ), - child: const SizedBox(width: 8, height: 8), - ), - SizedBox(width: theme.spacingXs), - Text( - '${issues.length} issue${issues.length == 1 ? '' : 's'}', - style: TextStyle(color: severityText, fontSize: theme.fontMd), - ), - ], - ); - }, - ); - } + /// The dot alone. + dot, } -/// Expanded-card detail row showing actualFps alongside throughputFps. -/// Visible only when the user taps the info icon on `_StatusRow`. -class _ThroughputDetailRow extends StatelessWidget { - const _ThroughputDetailRow({required this.controller}); - final SleuthController controller; +/// The header's run label: how the app was built and whether Sleuth has +/// its VM service link, with a dot in the severity text colours. +class _RunLabel extends StatelessWidget { + const _RunLabel({ + required this.mode, + required this.vmConnected, + required this.size, + }); + + /// "Debug" or "Profile". + final String mode; + final bool vmConnected; + final _RunLabelSize size; + + static TextStyle textStyle(SleuthThemeData theme) => TextStyle( + color: theme.textSecondary, + fontSize: theme.fontXs, + fontWeight: FontWeight.w600, + ); + + /// The full label's text. + static String text(String mode, bool vmConnected) => + '$mode · ${vmConnected ? 'VM' : 'no VM'}'; + + /// Width the pill adds around its text: padding, border, dot and gap. + static double chromeWidth(SleuthThemeData theme) => + 2 * theme.spacingMd + 2 + _dot + theme.spacingXs; + + static const double _dot = 7; @override Widget build(BuildContext context) { final theme = SleuthTheme.of(context); - return Padding( - padding: EdgeInsets.fromLTRB( - theme.spacingLg, - 0, - theme.spacingLg, - theme.spacingXs, - ), - child: ValueListenableBuilder( - valueListenable: controller.frameStatsNotifier, - builder: (_, buffer, _) { - final target = controller.config.fpsTarget; - final actual = buffer.actualFps.clamp(0.0, target.toDouble()); - final throughput = buffer.throughputFps.clamp(0.0, target.toDouble()); - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + return Semantics( + label: '$mode run, ${vmConnected ? 'VM connected' : 'no VM link'}', + excludeSemantics: true, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: theme.cardDivider), + borderRadius: BorderRadius.circular(theme.radiusXxl), + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: theme.spacingMd, + vertical: theme.spacingXxs, + ), + child: Row( + mainAxisSize: MainAxisSize.min, children: [ - _FpsCell(value: actual, target: target, label: 'ACTUAL'), - SizedBox(width: theme.spacingLg), - _FpsCell(value: throughput, target: target, label: 'TPUT'), + // Filled with the VM, a ring without it, so the state does + // not rest on colour once the text is shortened. + DecoratedBox( + decoration: vmConnected + ? BoxDecoration( + color: theme.severityOkText, + shape: BoxShape.circle, + ) + : BoxDecoration( + border: Border.all( + color: theme.severityWarningText, + width: 1.5, + ), + shape: BoxShape.circle, + ), + child: const SizedBox(width: _dot, height: _dot), + ), + if (size != _RunLabelSize.dot) ...[ + SizedBox(width: theme.spacingXs), + Text( + size == _RunLabelSize.short ? mode : text(mode, vmConnected), + style: textStyle(theme), + maxLines: 1, + softWrap: false, + ), + ], ], - ); - }, + ), + ), ), ); } } -class _FpsCell extends StatelessWidget { - const _FpsCell({ - required this.value, - required this.target, - required this.label, +/// "Showing X of Y" above the list while a severity is turned off, with +/// Show all, which turns every severity back on. +class _ShowingRow extends StatelessWidget { + const _ShowingRow({ + required this.shown, + required this.total, + required this.onShowAll, }); - final double value; - final int target; - final String label; + + final int shown; + final int total; + final VoidCallback onShowAll; @override Widget build(BuildContext context) { final theme = SleuthTheme.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - value.toStringAsFixed(0), - style: TextStyle( - color: theme.fpsTextColor(value, target: target), - fontSize: theme.fontXxl, - fontWeight: FontWeight.bold, + return Padding( + padding: EdgeInsets.only(left: theme.spacingLg, right: theme.spacingXs), + child: Row( + children: [ + Expanded( + // Read out when a chip changes the count. + child: Semantics( + liveRegion: true, + child: Text( + 'Showing $shown of $total', + style: TextStyle( + color: theme.textSecondary, + fontSize: theme.fontMd, + ), + ), + ), ), - ), - Text( - label, - style: TextStyle( - color: theme.textTertiary, - fontSize: theme.fontXs, - letterSpacing: 0.5, + Semantics( + button: true, + label: 'Show all', + onTap: onShowAll, + container: true, + excludeSemantics: true, + child: GestureDetector( + onTap: onShowAll, + behavior: HitTestBehavior.opaque, + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 48, minHeight: 48), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: theme.spacingSm), + child: Center( + widthFactor: 1, + child: Text( + 'Show all', + style: TextStyle( + color: theme.checkboxActive, + fontSize: theme.fontMd, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ), ), - ), - ], + ], + ), ); } } @@ -3144,392 +3368,95 @@ class _WarningBanners extends StatelessWidget { } } -// ─── Card Footer ──────────────────────────────────────────────────────── - -/// Footer text for [hidden] runtime-hidden and [suppressed] -/// config-suppressed issues, e.g. `2 hidden · 3 suppressed`; a part is -/// omitted when its count is 0, and null when both are. -@visibleForTesting -String? hiddenFooterLabel(int hidden, int suppressed) { - final parts = [ - if (hidden > 0) '$hidden hidden', - if (suppressed > 0) '$suppressed suppressed', - ]; - return parts.isEmpty ? null : parts.join(' · '); -} - -class _CardFooter extends StatelessWidget { - const _CardFooter({ - required this.controller, - required this.hiddenCount, - required this.onShowHidden, - required this.onExport, - required this.onEncyclopedia, - required this.onGuide, +/// A severity chip in the status row: the count and its word, which +/// toggles the severity filter. On, it is filled and shows the filled +/// severity icon; off, it is outlined in [SleuthThemeData.textQuaternary] +/// with the outlined icon, so the state does not rest on colour alone. The +/// hit box is at least 48 x 48. +class _SeverityChip extends StatelessWidget { + const _SeverityChip({ + super.key, + required this.severity, + required this.count, + required this.selected, + required this.onTap, }); - final SleuthController controller; - - /// Issues hidden from the overlay at runtime. - final int hiddenCount; - - /// Opens the Hidden list. - final VoidCallback onShowHidden; - final VoidCallback onExport; - final VoidCallback onEncyclopedia; - final VoidCallback onGuide; + final IssueSeverity severity; + final int count; + final bool selected; + final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = SleuthTheme.of(context); - return Container( - width: double.infinity, - // The right inset leaves the corner to the 48 px resize handle. - padding: EdgeInsets.only(left: theme.spacingMd, right: 48), - decoration: BoxDecoration( - border: Border(top: BorderSide(color: theme.border, width: 1)), - ), - child: SleuthTextScaleClamp( - maxScaleFactor: kChromeMaxTextScale, - // The hidden-issues action moves to a row of its own when it does - // not fit beside the three buttons (a narrow card, large text), so - // it keeps a 48 x 48 target and its whole label. - child: Wrap( - alignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Semantics( - label: 'Encyclopedia', - button: true, - child: GestureDetector( - onTap: onEncyclopedia, - behavior: HitTestBehavior.opaque, - child: SizedBox( - width: 48, - height: 48, - child: Center( - child: Icon( - Icons.menu_book_outlined, - color: theme.textTertiary, - size: 16, - ), + final accent = theme.severityColor(severity); + final tinted = theme.severityTextColor(severity); + final name = switch (severity) { + IssueSeverity.critical => 'critical', + IssueSeverity.warning => 'warning', + IssueSeverity.ok => 'ok', + }; + final icon = switch ((severity, selected)) { + (IssueSeverity.critical, true) => Icons.error, + (IssueSeverity.critical, false) => Icons.error_outline, + (IssueSeverity.warning, true) => Icons.warning, + (IssueSeverity.warning, false) => Icons.warning_amber, + (IssueSeverity.ok, true) => Icons.check_circle, + (IssueSeverity.ok, false) => Icons.check_circle_outline, + }; + final foreground = selected + ? theme.badgeTextOn(accent, tinted: tinted) + : theme.textTertiary; + return Semantics( + button: true, + selected: selected, + label: selected ? '$count $name, on' : '$count $name, off, tap to show', + onTap: onTap, + container: true, + excludeSemantics: true, + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 48, minHeight: 48), + child: Center( + widthFactor: 1, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 30), + child: DecoratedBox( + decoration: BoxDecoration( + color: selected ? theme.badgeFill(accent) : null, + borderRadius: BorderRadius.circular(theme.radiusFull), + border: Border.all( + color: selected ? tinted : theme.textQuaternary, + width: 1.5, ), ), - ), - ), - Semantics( - label: 'Export', - button: true, - child: GestureDetector( - onTap: onExport, - behavior: HitTestBehavior.opaque, - child: SizedBox( - width: 48, - height: 48, - child: Center( - child: Icon( - Icons.ios_share, - color: theme.textTertiary, - size: 16, - ), - ), - ), - ), - ), - Semantics( - label: 'Guide', - button: true, - child: GestureDetector( - onTap: onGuide, - behavior: HitTestBehavior.opaque, - child: SizedBox( - width: 48, - height: 48, - child: Center( - child: Icon( - Icons.help_outline, - color: theme.textTertiary, - size: 16, - ), - ), - ), - ), - ), - ValueListenableBuilder( - valueListenable: controller.suppressedCountNotifier, - builder: (_, suppressed, _) { - final label = hiddenFooterLabel(hiddenCount, suppressed); - if (label == null) return const SizedBox.shrink(); - return Semantics( - button: true, - label: '$label. Show hidden issues', - onTap: onShowHidden, - container: true, - excludeSemantics: true, - child: GestureDetector( - onTap: onShowHidden, - behavior: HitTestBehavior.opaque, - child: ConstrainedBox( - constraints: const BoxConstraints( - minWidth: 48, - minHeight: 48, - ), - child: Padding( - padding: EdgeInsets.only(left: theme.spacingMd), - child: Align( - alignment: Alignment.centerLeft, - widthFactor: 1, - // On a row of its own the label wraps rather - // than ellipsizes. - child: Text( - label, - style: TextStyle( - color: theme.textQuaternary, - fontSize: theme.fontSm, - decoration: TextDecoration.underline, - decorationColor: theme.textQuaternary, - ), - ), - ), - ), - ), - ), - ); - }, - ), - ], - ), - ), - ); - } -} - -// ─── Issues Summary Bar ────────────────────────────────────────────────── - -class _IssuesSummaryBar extends StatelessWidget { - const _IssuesSummaryBar({ - required this.chromeScale, - required this.issues, - required this.severityCounts, - required this.enabledSeverities, - required this.shownOfTotal, - required this.onToggleSeverity, - }); - - /// Cards currently shown; drives the confirmed/heuristic split. - final List issues; - - /// Cards of each severity the list shows with that severity enabled - /// and the others as they are (hidden cards excluded); drives the chip - /// counts. - final Map severityCounts; - - final Set enabledSeverities; - - /// Set when a severity is off or a card is hidden: shown and total - /// card counts for "Showing X of Y". - final ({int shown, int total})? shownOfTotal; - - final ValueChanged onToggleSeverity; - - /// Chrome text scale (1.0 to 1.3); the bar grows with it. - final double chromeScale; - - static const _order = [ - IssueSeverity.critical, - IssueSeverity.warning, - IssueSeverity.ok, - ]; - - /// Height the bar takes from the list at 1.0x text. - static const double barHeight = 36; - - /// Minimum height of the chips' hit boxes. - static const double minHitHeight = 48; - - /// Bar height at [chromeScale]. - static double barHeightFor(double chromeScale) => barHeight * chromeScale; - - /// Height of the chips' hit boxes at [chromeScale]: at least - /// [minHitHeight]. The part below the bar overlaps the top of the list - /// and takes taps only where a chip is. - static double hitHeightFor(double chromeScale) => - math.max(minHitHeight, barHeightFor(chromeScale)); - - /// [summary] over [body]: [body] starts one bar height below the top, - /// and [summary] is laid over it so the chips keep their hit boxes - /// without taking more height from the list. - static Widget above({ - required double chromeScale, - required Widget summary, - required Widget body, - }) => Stack( - children: [ - Positioned.fill(top: barHeightFor(chromeScale), child: body), - Positioned( - top: 0, - left: 0, - right: 0, - height: hitHeightFor(chromeScale), - child: SleuthTextScaleClamp( - maxScaleFactor: kChromeMaxTextScale, - child: summary, - ), - ), - ], - ); - - @override - Widget build(BuildContext context) { - final theme = SleuthTheme.of(context); - final counts = severityCounts; - var confirmed = 0; - var heuristic = 0; - for (final issue in issues) { - if (issue.confidence == IssueConfidence.confirmed) { - confirmed++; - } else { - heuristic++; - } - } - final narrowed = shownOfTotal; - final caption = narrowed != null - ? 'Showing ${narrowed.shown} of ${narrowed.total}' - : [ - if (confirmed > 0) '$confirmed confirmed', - if (heuristic > 0) '$heuristic heuristic', - ].join(' · '); - - // Only the chips take taps; the rest of the hit box lets them through - // to the list below. - final bar = barHeightFor(chromeScale); - return Stack( - children: [ - Positioned( - top: 0, - left: 0, - right: 0, - height: bar, - child: IgnorePointer( - child: DecoratedBox( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: theme.border, width: 1), - ), - ), - ), - ), - ), - Positioned.fill( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: theme.spacingSm), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final severity in _order) - // A disabled severity keeps its chip so it can be - // turned back on, even when it has no cards right now. - if ((counts[severity] ?? 0) > 0 || - !enabledSeverities.contains(severity)) - _SeverityChip( - chromeScale: chromeScale, - severity: severity, - count: counts[severity] ?? 0, - selected: enabledSeverities.contains(severity), - onTap: () => onToggleSeverity(severity), - ), - SizedBox(width: theme.spacingXs), - Expanded( - child: IgnorePointer( - child: SizedBox( - height: bar, - child: Align( - alignment: Alignment.centerRight, - child: Text( - caption, - style: TextStyle( - color: theme.textTertiary, - fontSize: theme.fontSm, - ), - textAlign: TextAlign.right, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ), - ), - ], - ), - ), - ), - ], - ); - } -} - -/// Severity count in the summary bar that toggles the overlay's severity -/// filter. The hit box is 48 x 48 at least; the pill sits centred in the -/// bar's visible height. -class _SeverityChip extends StatelessWidget { - const _SeverityChip({ - required this.chromeScale, - required this.severity, - required this.count, - required this.selected, - required this.onTap, - }); - - final double chromeScale; - final IssueSeverity severity; - final int count; - final bool selected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final theme = SleuthTheme.of(context); - final color = theme.severityColor(severity); - final name = switch (severity) { - IssueSeverity.critical => 'critical', - IssueSeverity.warning => 'warning', - IssueSeverity.ok => 'ok', - }; - return Semantics( - button: true, - selected: selected, - label: selected ? '$count $name, on' : '$count $name, off, tap to show', - onTap: onTap, - container: true, - excludeSemantics: true, - child: GestureDetector( - onTap: onTap, - behavior: HitTestBehavior.opaque, - child: ConstrainedBox( - constraints: const BoxConstraints( - minWidth: _IssuesSummaryBar.minHitHeight, - ), - child: SizedBox( - height: _IssuesSummaryBar.hitHeightFor(chromeScale), - child: Align( - alignment: Alignment.topCenter, - widthFactor: 1, - child: SizedBox( - height: _IssuesSummaryBar.barHeightFor(chromeScale), - child: Padding( - padding: EdgeInsets.symmetric(horizontal: theme.spacingXxs), - child: Center( - widthFactor: 1, - child: _SeverityChipPill( - color: color, - textColor: theme.badgeTextOn( - color, - tinted: theme.severityTextColor(severity), - ), - indicatorColor: theme.severityTextColor(severity), - count: count, - selected: selected, - ), + child: Padding( + padding: EdgeInsets.only( + left: theme.spacingSm, + right: theme.spacingMd, + top: theme.spacingXs, + bottom: theme.spacingXs, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: foreground), + SizedBox(width: theme.spacingXs), + Text( + '$count $name', + style: TextStyle( + color: foreground, + fontSize: theme.fontLg, + fontWeight: selected + ? FontWeight.w700 + : FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], ), ), ), @@ -3541,128 +3468,6 @@ class _SeverityChip extends StatelessWidget { } } -/// The visible pill of a [_SeverityChip]; animates between selected -/// (severity fill at [SleuthThemeData.badgeFillAlpha], a 1.5 px border in -/// [indicatorColor]) and unselected (border token at 0.5, muted text) over -/// 200 ms, or at once under reduced motion. A check before the count marks -/// a selected chip and a dot an unselected one, so the state does not rest -/// on colour alone. -class _SeverityChipPill extends StatefulWidget { - const _SeverityChipPill({ - required this.color, - required this.textColor, - required this.indicatorColor, - required this.count, - required this.selected, - }); - - final Color color; - - /// Count and check colour when selected. - final Color textColor; - - /// Border colour when selected: 3:1 or more against the card. - final Color indicatorColor; - final int count; - final bool selected; - - @override - State<_SeverityChipPill> createState() => _SeverityChipPillState(); -} - -class _SeverityChipPillState extends State<_SeverityChipPill> - with SingleTickerProviderStateMixin { - static const Duration _lerp = Duration(milliseconds: 200); - - late final AnimationController _selection = AnimationController( - vsync: this, - duration: _lerp, - value: widget.selected ? 1 : 0, - )..addListener(_tick); - - void _tick() => setState(() {}); - - /// Reduced motion turned on mid-change finishes it. - @override - void didChangeDependencies() { - super.didChangeDependencies(); - settleIfMotionReduced(context, _selection); - } - - @override - void didUpdateWidget(_SeverityChipPill oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.selected != oldWidget.selected) { - _selection.duration = motionDuration(context, _lerp); - if (widget.selected) { - _selection.forward(); - } else { - _selection.reverse(); - } - settleOnReducedMotion(context, _selection); - } - } - - @override - void dispose() { - _selection.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = SleuthTheme.of(context); - final t = _selection.value; - final color = widget.color; - final foreground = Color.lerp(theme.textQuaternary, widget.textColor, t)!; - return DecoratedBox( - decoration: BoxDecoration( - color: color.withValues(alpha: theme.badgeFillAlpha * t), - borderRadius: BorderRadius.circular(theme.radiusMd), - border: Border.all( - // High contrast keeps the unselected border at full strength. - color: Color.lerp( - theme.border.withValues(alpha: theme.badgeFillAlpha >= 1 ? 1 : 0.5), - widget.indicatorColor, - t, - )!, - width: 1 + 0.5 * t, - ), - ), - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: theme.spacingSm, - vertical: theme.spacingXxs, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.selected) - Icon(Icons.check, size: 10, color: foreground) - else - DecoratedBox( - decoration: BoxDecoration( - color: foreground, - shape: BoxShape.circle, - ), - child: const SizedBox(width: 6, height: 6), - ), - const SizedBox(width: 3), - Text( - '${widget.count}', - style: TextStyle( - color: foreground, - fontSize: theme.fontSm, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ); - } -} - /// Centered message with one action, shown when no card survives the /// severity filter or hiding. class _EmptyListMessage extends StatelessWidget { @@ -3763,575 +3568,3 @@ class _CornerGripPainter extends CustomPainter { bool shouldRepaint(covariant _CornerGripPainter oldDelegate) => gripColor != oldDelegate.gripColor; } - -// ─── Startup Metrics Banner ───────────────────────────────────────────── - -class _StartupMetricsBanner extends StatelessWidget { - const _StartupMetricsBanner({required this.onTap}); - - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final metrics = Sleuth.startupMetrics; - if (metrics == null) return const SizedBox.shrink(); - - final theme = SleuthTheme.of(context); - final parts = []; - if (metrics.ttffMs != null) { - parts.add('TTFF: ${metrics.ttffMs!.round()} ms'); - } - if (metrics.ttiMs != null) { - parts.add('TTI: ${metrics.ttiMs!.round()} ms'); - } - if (parts.isEmpty) return const SizedBox.shrink(); - - final color = theme.categoryStartup; - - return GestureDetector( - onTap: onTap, - behavior: HitTestBehavior.opaque, - child: Semantics( - label: 'Startup metrics, tap for details', - button: true, - child: DecoratedBox( - decoration: BoxDecoration(color: color.withValues(alpha: 0.1)), - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: theme.spacingSm, - vertical: theme.spacingXxs, - ), - // 48 px tall target; the banners scroll when the card is short. - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 48), - child: Row( - children: [ - Icon(Icons.rocket_launch_outlined, size: 12, color: color), - SizedBox(width: theme.spacingXs), - Expanded( - child: Text( - parts.join(' \u00B7 '), - style: TextStyle( - color: theme.textPrimary, - fontSize: theme.fontSm, - ), - overflow: TextOverflow.ellipsis, - ), - ), - Icon( - Icons.chevron_right, - size: 14, - color: color.withValues(alpha: 0.6), - ), - ], - ), - ), - ), - ), - ), - ); - } -} - -/// Always-on entry point to the per-route rebuild data. Renders an inline -/// banner whenever the active [RouteSession] has any rebuild attribution, -/// regardless of whether any detector has emitted a warning. This is the -/// sole data-discovery surface for rebuild stats since v0.15.2 — the -/// previous `rebuild_hotspot_summary` rollup IssueCard was removed because -/// (a) the panel covers both the data and the signal, (b) an always-pinned -/// IssueCard collided with ranker reorders, -/// and (c) profile-mode counts include first builds (inflations), which -/// made route entry look like a -/// warning storm in the issues list. -/// -/// **Two states:** -/// -/// * **Collapsed (default)** — single row with `Rebuilds: N across M -/// widgets` + a chevron. Tap to expand. -/// * **Expanded** — collapsed header + top-3 widget rows with rank, name, -/// live-tweened count, and a normalised bar fill. A Pause toggle freezes -/// the displayed counts so the user can read a stable snapshot. A -/// `See all M →` link pushes the full [RebuildStatsPage] drilldown via -/// the same snapshot-and-push handler the rollup card used to use. -/// -/// **Reactivity:** rebuilds on every scan tick (`scanTickNotifier`, the -/// pulse for rebuild-attribution updates) or when the active route session -/// itself changes (`routeHistoryNotifier`, which fires on route push/pop -/// and tab switches). The panel reads `controller.activeRouteSession` at -/// build time, so the union of these two notifiers is sufficient — no -/// extra per-frame work. -/// -/// **Pause semantics:** when the user taps Pause, the panel snapshots -/// `RouteSession.rebuildCountsByType` into [_frozenCounts] and renders -/// from that map until the user taps Resume. If the route session -/// changes while paused (auto-detected via [routeHistoryNotifier]), the -/// freeze is automatically cleared so the user is never looking at -/// stale data from a previous route — the new route's panel starts -/// fresh in live mode. -class _RebuildStatsBanner extends StatefulWidget { - const _RebuildStatsBanner({ - required this.controller, - required this.onTap, - required this.onPauseDiscarded, - }); - - final SleuthController controller; - - /// Called when the user taps `See all M →`. Reuses the same - /// snapshot-and-push code path the rollup IssueCard used before - /// v0.15.2 (`_FloatingIssuesCardState._onSeeAllRebuildsTap`), so the - /// drilldown's snapshot semantics are unchanged. - /// - /// When the panel is paused, the banner passes its [_frozenCounts] map - /// as [overrideCounts] so the drilldown opens against the same data the - /// user is currently reading on the panel — without this, a paused - /// panel showing N rebuilds would push a drilldown showing the live - /// (unfrozen) count, which is the snapshot-drift bug fixed in v0.15.2. - final void Function(Map? overrideCounts) onTap; - - /// Called when the panel auto-resumes due to a route change while the - /// user had it paused. The host card uses this to surface a transient - /// "Pause cleared — route changed" snackbar so the user is never - /// silently dropped from a frozen view back into live updates. - final VoidCallback onPauseDiscarded; - - @override - State<_RebuildStatsBanner> createState() => _RebuildStatsBannerState(); -} - -class _RebuildStatsBannerState extends State<_RebuildStatsBanner> { - /// Collapsed by default per the v0.15.2 UX choice. The panel header - /// stays one row tall when nothing surprising is happening; the user - /// expands only when they want to inspect the breakdown. - bool _expanded = false; - - /// True when the user has tapped Pause. Frozen counts in - /// [_frozenCounts] are rendered instead of the live session map. - bool _paused = false; - - /// Snapshot copy of `session.rebuildCountsByType` taken at the moment - /// the user tapped Pause. Defensive copy so subsequent live mutations - /// to the session map cannot reorder rows or change totals while the - /// user is reading a frozen view. Cleared on Resume or on route change. - Map? _frozenCounts; - - /// Hoisted `Listenable.merge` so the panel attaches its - /// listeners exactly once instead of allocating a fresh merge wrapper - /// (which detaches and re-attaches both source listeners) on every - /// build. The panel rebuilds frequently — once per scan tick plus - /// once per route push/pop — so the per-build allocation showed up as - /// pointless churn in the listener path. Field is `late final` and - /// set in [initState] from the same two source notifiers that used - /// to be merged inline in `build()`. - late final Listenable _mergedListenable; - - @override - void initState() { - super.initState(); - _mergedListenable = Listenable.merge([ - widget.controller.scanTickNotifier, - widget.controller.routeHistoryNotifier, - ]); - // Auto-resume on route change: a frozen view of route A's counts is - // confusing once the user has navigated to route B. The panel's - // listener clears the freeze the instant a new session is created, - // so the new route's panel starts fresh in live mode without the - // user having to manually tap Resume. - widget.controller.routeHistoryNotifier.addListener(_onRouteSessionChanged); - } - - @override - void dispose() { - widget.controller.routeHistoryNotifier.removeListener( - _onRouteSessionChanged, - ); - super.dispose(); - } - - void _onRouteSessionChanged() { - if (!mounted) return; - if (!_paused) return; - setState(() { - _paused = false; - _frozenCounts = null; - }); - // Notify the host card so it can surface a transient - // "Pause cleared — route changed" snackbar. Without this signal the - // user comes back from a tab swap to find their pause silently gone. - widget.onPauseDiscarded(); - } - - void _toggleExpanded() { - setState(() => _expanded = !_expanded); - } - - void _togglePause() { - setState(() { - if (_paused) { - _paused = false; - _frozenCounts = null; - return; - } - final session = widget.controller.activeRouteSession; - if (session == null) return; - _frozenCounts = Map.of(session.rebuildCountsByType); - _paused = true; - }); - } - - @override - Widget build(BuildContext context) { - return SleuthListenableBuilder( - // Hoisted merge — see field declaration. Allocating - // `Listenable.merge(...)` inline here would re-create the wrapper - // on every build and detach/re-attach both source listeners. - listenable: _mergedListenable, - builder: (context) { - final session = widget.controller.activeRouteSession; - // Distinguish "no session" from "session exists but no - // counts" — the latter is debug-info-worthy when the user is - // expecting to see attribution. Both paths still suppress the - // panel from view, but with explicit reasons rather than a - // single silent SizedBox.shrink() that hides three different - // failure modes (null session, empty map, zero total). - if (session == null) return const SizedBox.shrink(); - final liveCounts = session.rebuildCountsByType; - // Source-of-truth selection — frozen wins over live when paused. - final counts = _paused && _frozenCounts != null - ? _frozenCounts! - : liveCounts; - if (counts.isEmpty) return const SizedBox.shrink(); - final total = counts.values.fold(0, (a, b) => a + b); - if (total <= 0) return const SizedBox.shrink(); - final widgetCount = counts.length; - - // Sort once per build — counts are tiny (10s of entries at most) - // so this is cheap. Top-3 lifted out of the same sorted list to - // avoid a second pass. - final sorted = counts.entries.toList() - ..sort((a, b) => b.value.compareTo(a.value)); - final top = sorted.take(_topN).toList(growable: false); - final topMax = top.isNotEmpty ? top.first.value : 0; - - final theme = SleuthTheme.of(context); - final color = theme.categoryBuild; - - return DecoratedBox( - decoration: BoxDecoration(color: color.withValues(alpha: 0.1)), - // Tap-target compromise: tightened the panel's internal vertical - // padding (4 → 2) and the header→rows spacer (8 → 2) to - // reclaim the pixels spent on enlarged tap targets, so the - // expanded panel still fits the cramped 330dp overlay budget. - child: Padding( - padding: EdgeInsets.fromLTRB( - theme.spacingSm, - 2, - theme.spacingSm, - 2, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildHeaderRow(theme, color, total, widgetCount), - if (_expanded) ...[ - const SizedBox(height: 2), - for (var i = 0; i < top.length; i++) - _buildTopRow( - theme: theme, - color: color, - rank: i + 1, - typeName: top[i].key, - count: top[i].value, - barFraction: topMax == 0 ? 0.0 : top[i].value / topMax, - ), - _buildExpandedFooter(theme, color, widgetCount), - ], - ], - ), - ), - ); - }, - ); - } - - /// v0.15.2 UX knob: the panel surfaces the top-3 rebuilders inline. - /// The full list is reachable via the `See all M →` drilldown link. - /// Three is the sweet spot for "scan-at-a-glance" — five rows turns - /// the panel into a list and competes with the issue cards below it. - static const int _topN = 3; - - Widget _buildHeaderRow( - SleuthThemeData theme, - Color color, - int total, - int widgetCount, - ) { - final widgetWord = widgetCount == 1 ? 'widget' : 'widgets'; - final summary = 'Rebuilds: $total across $widgetCount $widgetWord'; - final pausedHint = _paused ? ', paused' : ''; - final semanticsHint = _expanded - ? '$summary$pausedHint, expanded, tap to collapse' - : '$summary$pausedHint, collapsed, tap to expand'; - - return Semantics( - label: semanticsHint, - button: true, - // A full-width row at least 48 px tall; the banners scroll when the - // card is short. `HitTestBehavior.opaque` makes every pixel of the - // row hittable. - child: GestureDetector( - onTap: _toggleExpanded, - behavior: HitTestBehavior.opaque, - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 48), - child: Row( - children: [ - Icon(Icons.repeat, size: 12, color: color), - SizedBox(width: theme.spacingXs), - Expanded( - child: Text( - summary, - style: TextStyle( - color: theme.textPrimary, - fontSize: theme.fontSm, - ), - overflow: TextOverflow.ellipsis, - ), - ), - // Pause indicator on the COLLAPSED header. Without - // this, a user who pauses, collapses, and walks away has - // no visual signal that the displayed total is frozen. - if (!_expanded && _paused) ...[ - SizedBox(width: theme.spacingXxs), - // A text token keeps the state icon at 3:1 or more on - // the tinted banner. - Icon(Icons.pause, size: 10, color: theme.textSecondary), - ], - if (_expanded) ...[ - Semantics( - label: _paused - ? 'Resume live rebuild updates' - : 'Pause live rebuild updates', - button: true, - // 48 x 48 hit box; the banners scroll when the card is - // short. `HitTestBehavior.opaque` is critical: without it - // the OUTER header GestureDetector would intercept - // the tap when the finger lands on the padding rather - // than on the icon glyph itself, toggling expansion - // instead of pause/resume. - child: SizedBox( - width: 48, - height: 48, - child: GestureDetector( - onTap: _togglePause, - behavior: HitTestBehavior.opaque, - child: Center( - child: Icon( - _paused ? Icons.play_arrow : Icons.pause, - size: 14, - color: color, - ), - ), - ), - ), - ), - ], - Icon( - _expanded ? Icons.expand_less : Icons.expand_more, - size: 14, - color: color.withValues( - alpha: theme.badgeFillAlpha >= 1 ? 1 : 0.7, - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildTopRow({ - required SleuthThemeData theme, - required Color color, - required int rank, - required String typeName, - required int count, - required double barFraction, - }) { - // Tap-target compromise: per-row bottom gap tightened from spacingXxs (4dp) - // to 2dp so 3 rows reclaim 6dp toward the enlarged tap-target - // budget. The bar still has visible separation thanks to the row's - // intrinsic Text + bar layout. - return Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - SizedBox( - width: 18, - child: Text( - '$rank.', - style: TextStyle( - color: theme.textTertiary, - fontSize: theme.fontXs, - fontWeight: FontWeight.w600, - ), - ), - ), - Expanded( - child: Text( - typeName, - style: TextStyle( - color: theme.textPrimary, - fontSize: theme.fontSm, - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - SizedBox(width: theme.spacingXs), - // Animated count tween — when live counts move, the number - // smoothly interpolates so eye-catching frames don't feel - // like a glitch. 200ms matches Material's "short" duration - // — long enough to read, short enough not to lag behind a - // 1-second scan tick. - // - // `IntTween(begin: 0, end: count)` is the canonical - // pattern. On first appearance the row tweens from 0 → N; - // on subsequent rebuilds with a different `end`, - // TweenAnimationBuilder's `didUpdateWidget` substitutes - // the current animated value as the new `begin` and - // animates from the old end to the new one. The previous - // `IntTween(begin: count, end: count)` form happened to - // work because the substitution overwrites `begin`, but - // it misled the reader and set the wrong starting value - // when the row first appeared. - TweenAnimationBuilder( - key: ValueKey(typeName), - tween: IntTween(begin: 0, end: count), - duration: motionDuration( - context, - const Duration(milliseconds: 200), - ), - builder: (context, value, _) => Text( - '\u00d7$value', - style: TextStyle( - color: theme.textPrimary, - fontSize: theme.fontSm, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - // Tap-target compromise: 1dp gap + 2dp bar (from 4dp + 3dp) reclaims - // 4dp/row × 3 rows = 12dp toward the enlarged tap-target - // budget. The bar is still a visible rule. - const SizedBox(height: 1), - Padding( - padding: const EdgeInsets.only(left: 18), - child: ClipRRect( - borderRadius: BorderRadius.circular(2), - child: SizedBox( - height: 2, - child: LinearProgressIndicator( - value: barFraction.clamp(0.0, 1.0), - backgroundColor: color.withValues(alpha: 0.15), - valueColor: AlwaysStoppedAnimation(color), - ), - ), - ), - ), - ], - ), - ); - } - - Widget _buildExpandedFooter( - SleuthThemeData theme, - Color color, - int widgetCount, - ) { - // The "See all N →" link is only meaningful when the drilldown - // would actually surface widgets that are NOT already shown inline. - // With top-N = 3, a route with ≤ 3 widgets has nothing to drill into, - // so the link is suppressed to avoid a redundant tap target. - final showSeeAll = widgetCount > _topN; - return SleuthTextScaleClamp( - maxScaleFactor: kChromeMaxTextScale, - child: _expandedFooterRow(theme, color, widgetCount, showSeeAll), - ); - } - - Widget _expandedFooterRow( - SleuthThemeData theme, - Color color, - int widgetCount, - bool showSeeAll, - ) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - // Inflation footnote (profile counts include first builds), the - // short form of the disclaimer that the drilldown page renders in - // full. The inline panel only has room for a one-liner; users who - // need the full caveat (and the note that Sleuth's own overlay - // widgets are excluded) tap through to the drilldown. - Flexible( - child: Text( - 'includes inflations', - style: TextStyle( - color: theme.textTertiary, - fontSize: theme.fontXxs, - fontStyle: FontStyle.italic, - ), - overflow: TextOverflow.ellipsis, - ), - ), - if (showSeeAll) ...[ - SizedBox(width: theme.spacingSm), - Semantics( - label: 'See all $widgetCount rebuilds', - button: true, - // 48 px tall hit box. `HitTestBehavior.opaque` makes the - // whole padded box receive taps even where the text doesn't - // cover it. - child: SizedBox( - height: 48, - child: GestureDetector( - onTap: () { - // Pass the panel's frozen snapshot through to the - // drilldown when paused, so the drilldown opens against - // the same data the user is reading on the panel. - widget.onTap(_paused ? _frozenCounts : null); - }, - behavior: HitTestBehavior.opaque, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: theme.spacingXs), - child: Center( - widthFactor: 1, - child: Text( - 'See all $widgetCount', - style: TextStyle( - color: theme.textPrimary, - fontSize: theme.fontXs, - fontWeight: FontWeight.w600, - decoration: TextDecoration.underline, - decorationColor: theme.textPrimary, - ), - ), - ), - ), - ), - ), - ), - ], - ], - ); - } -} diff --git a/lib/src/ui/focus_ink_well.dart b/lib/src/ui/focus_ink_well.dart new file mode 100644 index 00000000..a0f12ddf --- /dev/null +++ b/lib/src/ui/focus_ink_well.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +/// An [InkWell] that tells [builder] when to draw a keyboard focus +/// outline. +/// +/// The overlay sits above the app's `MaterialApp`, so an InkWell's own +/// focus fill comes from `ThemeData.fallback()` and barely shows on the +/// card. [builder] gets true while the well has focus and the keyboard +/// drives focus (`FocusHighlightMode.traditional`), the same rule +/// InkWell applies to its fill. +class SleuthFocusInkWell extends StatefulWidget { + const SleuthFocusInkWell({ + super.key, + required this.onTap, + required this.borderRadius, + required this.builder, + this.focusNode, + }); + + /// Null disables the well; it then takes no focus. + final VoidCallback? onTap; + + /// The ink splash's shape. + final BorderRadius borderRadius; + + final FocusNode? focusNode; + + /// Builds the child; `showFocus` is true while the outline should show. + final Widget Function(BuildContext context, bool showFocus) builder; + + @override + State createState() => _SleuthFocusInkWellState(); +} + +class _SleuthFocusInkWellState extends State { + bool _focused = false; + bool _keyboard = + FocusManager.instance.highlightMode == FocusHighlightMode.traditional; + + @override + void initState() { + super.initState(); + FocusManager.instance.addHighlightModeListener(_onHighlightMode); + } + + @override + void dispose() { + FocusManager.instance.removeHighlightModeListener(_onHighlightMode); + super.dispose(); + } + + void _onHighlightMode(FocusHighlightMode mode) { + final keyboard = mode == FocusHighlightMode.traditional; + if (keyboard != _keyboard) setState(() => _keyboard = keyboard); + } + + @override + Widget build(BuildContext context) => InkWell( + onTap: widget.onTap, + focusNode: widget.focusNode, + borderRadius: widget.borderRadius, + focusColor: Colors.transparent, + onFocusChange: (focused) { + if (focused != _focused) setState(() => _focused = focused); + }, + child: widget.builder(context, _focused && _keyboard), + ); +} + +/// The outline [SleuthFocusInkWell] callers draw: 2 px of [color] around +/// [child], or nothing while [show] is false. +class SleuthFocusOutline extends StatelessWidget { + const SleuthFocusOutline({ + super.key, + required this.show, + required this.color, + required this.borderRadius, + required this.child, + }); + + final bool show; + final Color color; + final BorderRadius borderRadius; + final Widget child; + + @override + Widget build(BuildContext context) => DecoratedBox( + position: DecorationPosition.foreground, + decoration: show + ? BoxDecoration( + border: Border.all(color: color, width: 2), + borderRadius: borderRadius, + ) + : const BoxDecoration(), + child: child, + ); +} diff --git a/lib/src/ui/guide_page.dart b/lib/src/ui/guide_page.dart index cafc2a5c..6c51d10c 100644 --- a/lib/src/ui/guide_page.dart +++ b/lib/src/ui/guide_page.dart @@ -364,9 +364,19 @@ class _GuidePageState extends State theme, '\u{1F4CA}', 'FPS display', - 'The number on the trigger button and the card status row. Its ' - 'color follows your target frame rate. It is green at or above ' - 'about 83%, amber at or above 50% and red below that.', + 'The number on the trigger button, coloured by your target frame ' + 'rate: green at or above about 83%, amber at or above 50% and ' + 'red below that. The status row repeats it at its end.', + ), + _infoTile( + theme, + '\u{1F7E2}', + 'Run label', + 'The header names the build mode and whether Sleuth reached the ' + 'VM service, for example "Debug · VM" or "Profile · no VM". ' + 'Without the VM, the checks that need it are off and some run ' + 'in part. The dot is green with the VM and amber without it. ' + 'A narrow card shortens the text, down to the dot alone.', ), _infoTile( theme, @@ -374,17 +384,16 @@ class _GuidePageState extends State 'Severity dot', 'Red means at least one critical issue. Amber means warnings only. ' 'Green means no issues. Hidden cards and severities turned off ' - 'in the summary bar do not count.', + 'in the status row do not count.', ), _infoTile( theme, '\u{2705}', - 'Summary bar', - 'Each severity count is also a filter. Tap one to show or hide ' - 'that severity. One always stays on. The right side shows ' - 'evidence quality (confirmed and heuristic counts), or ' - '"Showing X of Y" while a filter or a hidden card narrows the ' - 'list.', + 'Severity chips', + 'The chips under the header count the cards of each severity and ' + 'filter the list. Tap one to show or hide that severity. One ' + 'always stays on. While one is off, "Showing X of Y" sits ' + 'above the list, with Show all.', ), _infoTile( theme, @@ -396,13 +405,11 @@ class _GuidePageState extends State _infoTile( theme, '\u{1F501}', - 'Rebuild stats panel', - 'An always-on row above the issue list, in profile mode with ' - 'enableDeepDebugInstrumentation. It shows total rebuilds and ' - 'the unique widget count. Tap it to expand the top 3 widgets ' - 'with live counts and bar fills. Pause freezes the snapshot and ' - 'Resume unfreezes it. See all N opens the full drilldown. ' - 'Counts include initial widget inflations.', + 'Rebuild stats', + 'In profile mode with enableDeepDebugInstrumentation, the More ' + 'menu shows the rebuilds counted on this route. Tap Rebuild ' + 'stats for every widget type, sorted by count, as they were ' + 'when you tapped. Counts include initial widget inflations.', ), _infoTile( theme, @@ -410,9 +417,9 @@ class _GuidePageState extends State 'Copy and hide', 'Expand an issue to copy its details as plain text, or long-press ' 'the title to copy them. You can also hide the card, with Undo. ' - 'The footer shows how many cards are hidden. Tap it to restore ' - 'them. Hiding affects only the overlay. Exports, snapshots and ' - 'MCP still include the issue.', + 'Hidden issues in the More menu counts them and opens the list ' + 'where you restore them. Hiding affects only the overlay. ' + 'Exports, snapshots and MCP still include the issue.', ), _infoTile( theme, @@ -432,9 +439,9 @@ class _GuidePageState extends State _infoTile( theme, '\u{1F4CB}', - 'Export', - 'Tap the export button in the footer to copy a JSON snapshot ' - 'of the current session to your clipboard.', + 'Copy snapshot', + 'Copy snapshot in the More menu copies a JSON snapshot of the ' + 'current session to your clipboard.', ), ], ); @@ -988,8 +995,8 @@ class _GuidePageState extends State ), _tipRow( theme, - Icons.ios_share, - 'Export a JSON snapshot to share with teammates or attach to bug reports.', + Icons.content_copy, + 'Copy a JSON snapshot from the More menu to share with teammates or attach to bug reports.', ), _tipRow( theme, @@ -1031,7 +1038,7 @@ class _GuidePageState extends State _tipRow( theme, Icons.contrast, - 'The theme button cycles System, Light and Dark. Increase ' + 'Theme in the More menu picks Auto, Light or Dark. Increase ' 'Contrast picks the high-contrast theme.', ), _tipRow( diff --git a/lib/src/ui/more_menu.dart b/lib/src/ui/more_menu.dart new file mode 100644 index 00000000..dfd7ed78 --- /dev/null +++ b/lib/src/ui/more_menu.dart @@ -0,0 +1,458 @@ +import 'package:flutter/foundation.dart' show defaultTargetPlatform; +import 'package:flutter/material.dart'; + +import 'focus_ink_well.dart'; +import 'motion.dart'; +import 'overlay_ui_state.dart'; +import 'sleuth_theme.dart'; + +/// What the More menu shows, read once when it opens. +typedef MoreMenuValues = ({ + /// Time to first frame, or null before it is measured. + double? ttffMs, + + /// Rebuilds counted on the current route, or null when there are none. + int? rebuildTotal, + + /// Issues hidden from the overlay or suppressed by the config. + int hiddenCount, +}); + +/// The panel the header's More button opens inside the card. +/// +/// The overlay sits outside the app's `Navigator`, so a route-based menu +/// cannot open there; the card shows this panel over a scrim instead. It +/// is built only while open and does not listen to scan updates. +class SleuthMoreMenu extends StatefulWidget { + const SleuthMoreMenu({ + super.key, + this.semanticsKey, + required this.values, + required this.themeMode, + required this.focusScope, + required this.maxHeight, + required this.onStartup, + required this.onRebuildStats, + required this.onHiddenIssues, + required this.onEncyclopedia, + required this.onGuide, + required this.onCopySnapshot, + required this.onThemeMode, + }); + + /// Key of the panel's semantics node, so the card can move screen + /// reader focus to it. + final Key? semanticsKey; + + final MoreMenuValues values; + final SleuthThemeMode themeMode; + + /// Keyboard focus stays inside this scope while the menu is open. + final FocusScopeNode focusScope; + + /// The panel scrolls above this height. + final double maxHeight; + + final VoidCallback onStartup; + final VoidCallback onRebuildStats; + final VoidCallback onHiddenIssues; + final VoidCallback onEncyclopedia; + final VoidCallback onGuide; + final VoidCallback onCopySnapshot; + + /// Applies a theme mode; the menu stays open. + final ValueChanged onThemeMode; + + @override + State createState() => _SleuthMoreMenuState(); +} + +class _SleuthMoreMenuState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _fade = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + startEntrance(context, _fade); + } + + @override + void dispose() { + _fade.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = SleuthTheme.of(context); + final values = widget.values; + final ttff = values.ttffMs; + final rebuilds = values.rebuildTotal; + final radius = BorderRadius.circular(theme.radiusXxl); + Widget separator() => Padding( + padding: EdgeInsets.symmetric(vertical: theme.spacingXxs), + child: SizedBox( + height: 1, + width: double.infinity, + child: DecoratedBox( + decoration: BoxDecoration(color: theme.cardDivider), + ), + ), + ); + return FadeTransition( + opacity: _fade, + // The panel is in the semantics tree from its first frame, when the + // card moves screen reader focus to it. + alwaysIncludeSemantics: true, + child: FocusScope( + node: widget.focusScope, + autofocus: true, + child: Semantics( + key: widget.semanticsKey, + container: true, + explicitChildNodes: true, + label: 'More', + child: DecoratedBox( + decoration: BoxDecoration( + color: theme.cardBackground, + border: Border.all(color: theme.border), + borderRadius: radius, + boxShadow: [ + BoxShadow( + color: theme.shadow, + blurRadius: 28, + offset: const Offset(0, 10), + ), + ], + ), + child: ClipRRect( + borderRadius: radius, + child: Material( + type: MaterialType.transparency, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: widget.maxHeight), + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(vertical: theme.spacingXs), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _MenuRow( + icon: Icons.rocket_launch_outlined, + label: 'Startup', + value: ttff == null + ? 'Not measured yet' + : 'TTFF ${ttff.round()} ms', + semanticsValue: ttff == null + ? null + : 'time to first frame ${ttff.round()} ' + 'milliseconds', + onTap: ttff == null ? null : widget.onStartup, + ), + _MenuRow( + icon: Icons.autorenew, + label: 'Rebuild stats', + value: rebuilds == null + ? 'No counts yet' + : _grouped(rebuilds), + onTap: rebuilds == null + ? null + : widget.onRebuildStats, + ), + if (values.hiddenCount > 0) + _MenuRow( + icon: Icons.visibility_off_outlined, + label: 'Hidden issues', + value: '${values.hiddenCount}', + onTap: widget.onHiddenIssues, + ), + separator(), + _MenuRow( + icon: Icons.menu_book_outlined, + label: 'Encyclopedia', + onTap: widget.onEncyclopedia, + ), + _MenuRow( + icon: Icons.help_outline, + label: 'Guide', + onTap: widget.onGuide, + ), + separator(), + _MenuRow( + icon: Icons.content_copy_outlined, + label: 'Copy snapshot', + onTap: widget.onCopySnapshot, + ), + separator(), + _ThemeRow( + mode: widget.themeMode, + onChanged: widget.onThemeMode, + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + /// [n] with thousands separators: 2389 reads 2,389. + static String _grouped(int n) { + final digits = '$n'; + final out = StringBuffer(); + for (var i = 0; i < digits.length; i++) { + if (i > 0 && (digits.length - i) % 3 == 0) out.write(','); + out.write(digits[i]); + } + return out.toString(); + } +} + +/// A full-width menu command, at least 48 px tall. A null [onTap] draws it +/// disabled. +class _MenuRow extends StatelessWidget { + const _MenuRow({ + required this.icon, + required this.label, + this.value, + this.semanticsValue, + this.onTap, + }); + + final IconData icon; + final String label; + final String? value; + + /// [value] as a screen reader should say it, when the shown text + /// abbreviates. + final String? semanticsValue; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = SleuthTheme.of(context); + final enabled = onTap != null; + final value = this.value; + final spoken = semanticsValue ?? value; + return Semantics( + button: true, + enabled: enabled, + label: spoken == null ? label : '$label, $spoken', + onTap: onTap, + excludeSemantics: true, + child: SleuthFocusInkWell( + onTap: onTap, + borderRadius: BorderRadius.zero, + builder: (context, showFocus) => SleuthFocusOutline( + show: showFocus, + color: theme.checkboxActive, + borderRadius: BorderRadius.zero, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: theme.spacingLg), + child: Row( + children: [ + Icon( + icon, + size: 16, + color: enabled ? theme.textSecondary : theme.textQuaternary, + ), + SizedBox(width: theme.spacingMd), + // The value sits at the end, or under the label when the + // two do not fit on one line. + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(vertical: theme.spacingSm), + child: Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: theme.spacingMd, + children: [ + Text( + label, + style: TextStyle( + color: enabled + ? theme.textPrimary + : theme.textTertiary, + fontSize: theme.fontLg, + ), + ), + if (value != null) + Text( + value, + style: TextStyle( + color: theme.textTertiary, + fontSize: theme.fontMd, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +/// Theme as a three-way switch: Auto, Light and Dark. The current choice +/// is filled and outlined, with a filled icon, so the result of a tap +/// shows before it. +class _ThemeRow extends StatelessWidget { + const _ThemeRow({required this.mode, required this.onChanged}); + + final SleuthThemeMode mode; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final theme = SleuthTheme.of(context); + // iOS reads the current choice of a radio group from the selected + // flag, as RawRadio sets it; Android reads the checked state. + final readsSelected = switch (defaultTargetPlatform) { + TargetPlatform.iOS || TargetPlatform.macOS => true, + _ => false, + }; + final segmentRadius = BorderRadius.circular(theme.radiusMd); + final focusRadius = BorderRadius.circular(theme.radiusLg); + Widget segment( + SleuthThemeMode value, + String name, + Widget Function(bool on) face, + ) { + final on = mode == value; + return Semantics( + button: true, + inMutuallyExclusiveGroup: true, + checked: on, + selected: readsSelected ? on : null, + label: '$name theme', + onTap: () => onChanged(value), + excludeSemantics: true, + child: SleuthFocusInkWell( + onTap: () => onChanged(value), + borderRadius: focusRadius, + builder: (context, showFocus) => SizedBox( + width: 48, + height: 48, + child: Center( + // The focus outline sits 1 px outside the segment. + child: SleuthFocusOutline( + show: showFocus, + color: theme.checkboxActive, + borderRadius: focusRadius, + child: SizedBox( + width: 48, + height: 34, + child: Center( + child: SizedBox( + width: 44, + height: 30, + child: DecoratedBox( + decoration: BoxDecoration( + color: on + ? theme.checkboxActive.withValues(alpha: 0.24) + : null, + border: on + ? Border.all( + color: theme.checkboxActive, + width: 1.5, + ) + : null, + borderRadius: segmentRadius, + ), + child: Center(child: face(on)), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + Widget icon(IconData data) => + Icon(data, size: 15, color: theme.textSecondary); + return Padding( + padding: EdgeInsets.only(left: theme.spacingLg, right: theme.spacingXs), + child: Row( + children: [ + Icon(Icons.contrast, size: 16, color: theme.textSecondary), + SizedBox(width: theme.spacingMd), + Expanded( + child: Text( + 'Theme', + style: TextStyle( + color: theme.textPrimary, + fontSize: theme.fontLg, + ), + ), + ), + // The outline is 32 px tall; each segment takes taps over 48 px. + Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: 3 * 48, + height: 32, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: theme.cardDivider), + borderRadius: BorderRadius.circular(theme.radiusLg), + ), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + segment( + SleuthThemeMode.system, + 'Auto', + (on) => Text( + 'Auto', + style: TextStyle( + color: on ? theme.textPrimary : theme.textSecondary, + fontSize: theme.fontBase, + fontWeight: on ? FontWeight.w600 : FontWeight.normal, + ), + ), + ), + segment( + SleuthThemeMode.light, + 'Light', + (on) => + icon(on ? Icons.light_mode : Icons.light_mode_outlined), + ), + segment( + SleuthThemeMode.dark, + 'Dark', + (on) => + icon(on ? Icons.dark_mode : Icons.dark_mode_outlined), + ), + ], + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/src/ui/rebuild_stats_page.dart b/lib/src/ui/rebuild_stats_page.dart index 3e1f7002..9df204e2 100644 --- a/lib/src/ui/rebuild_stats_page.dart +++ b/lib/src/ui/rebuild_stats_page.dart @@ -6,8 +6,8 @@ import 'sleuth_theme.dart'; /// Full-screen drilldown page showing every widget type rebuilt during the /// active [RouteSession]'s lifetime, sorted descending by count. /// -/// Opened by tapping the "See all N" link inside the expanded -/// `_RebuildStatsBanner` panel on the floating issues card. Mirrors the +/// Opened from the More menu's Rebuild stats row on the floating issues +/// card. Mirrors the /// [Positioned.fill] overlay chrome used by /// [StartupMetricsPage] / [GuidePage] so it composes identically inside the /// floating card's overlay stack. diff --git a/lib/src/ui/sleuth_overlay.dart b/lib/src/ui/sleuth_overlay.dart index 71a21b6c..8b79ae3a 100644 --- a/lib/src/ui/sleuth_overlay.dart +++ b/lib/src/ui/sleuth_overlay.dart @@ -78,6 +78,11 @@ class _SleuthOverlayState extends State /// semantics are dropped so a screen reader stays on the page. final ValueNotifier _fullScreenLayerOpen = ValueNotifier(false); + /// True while a full-screen layer or the card's More menu is open; the + /// app's semantics are dropped below the overlay. The menu leaves the + /// app's focus alone: it keeps keyboard focus in its own panel. + final ValueNotifier _appSemanticsBlocked = ValueNotifier(false); + /// Key of the [ExcludeFocus] around the app; it keeps the app's subtree /// in place and tells app focus from overlay focus. final GlobalKey _appFocusKey = GlobalKey(debugLabel: 'Sleuth app focus'); @@ -90,6 +95,11 @@ class _SleuthOverlayState extends State /// The app's primary focus when the exclusion began, restored when it /// ends. FocusNode? _appFocusBeforeLayer; + + /// The app's primary focus when the More menu took keyboard focus, + /// given back when the dashboard closes. Closing the menu itself sends + /// focus to the More button instead. + FocusNode? _appFocusBeforeMenu; bool _focusSyncScheduled = false; @override @@ -149,8 +159,23 @@ class _SleuthOverlayState extends State /// a layer is open ([_watchAppNavigation]). Never cleared from here. void _onLayersChanged() { final Object? host = _cardKey.currentState; - _fullScreenLayerOpen.value = - _layerOpen && host is OverlayLayerHost && host.openLayerDepth > 0; + final layers = _layerOpen && host is OverlayLayerHost; + _fullScreenLayerOpen.value = layers && host.openLayerDepth > 0; + _appSemanticsBlocked.value = + layers && (host.openLayerDepth > 0 || host.menuOpen); + // Reported from the card's build, before the menu's scope takes + // focus, so the app's node is still the primary focus here. + if (layers && host.menuOpen && _appFocusBeforeMenu == null) { + final focus = FocusManager.instance.primaryFocus; + if (focus != null && _isAppFocus(focus)) _appFocusBeforeMenu = focus; + } + // With a page still excluding the app's focus, [_restoreAppFocus] + // gives the node back once the exclusion ends. + if (!_layerOpen && _appFocusBeforeMenu != null && !_appFocusExcluded) { + final node = _appFocusBeforeMenu; + _appFocusBeforeMenu = null; + WidgetsBinding.instance.addPostFrameCallback((_) => _giveFocusBack(node)); + } _scheduleFocusSync(); _requestFrameworkBack(); _watchAppNavigation(); @@ -196,9 +221,18 @@ class _SleuthOverlayState extends State void _restoreAppFocus() { // Excluded again: the node waits for the next close. if (!mounted || _appFocusExcluded) return; - final node = _appFocusBeforeLayer; + // A page opened from the More menu: the menu took the app's focus + // first. + final node = _appFocusBeforeLayer ?? _appFocusBeforeMenu; _appFocusBeforeLayer = null; - if (node == null) return; + _appFocusBeforeMenu = null; + _giveFocusBack(node); + } + + /// Focuses [node] again when it is still in the tree and focusable and + /// the app has not taken focus itself. + void _giveFocusBack(FocusNode? node) { + if (!mounted || node == null) return; final nodeContext = node.context; if (nodeContext == null || !nodeContext.mounted || @@ -461,7 +495,7 @@ class _SleuthOverlayState extends State // Above the overlay's Localizations, whose semantics node // would stop a BlockSemantics placed inside the card. child: _AppSemanticsBlocker( - blocking: _fullScreenLayerOpen, + blocking: _appSemanticsBlocked, child: _clampTextScale( context, Localizations( @@ -594,6 +628,7 @@ class _SleuthOverlayState extends State void dispose() { OverlayOwnership.unregister(context as Element, _appFocusKey); _fullScreenLayerOpen.dispose(); + _appSemanticsBlocked.dispose(); widget.controller.themeOverride.removeListener(_onThemeChanged); widget.controller.overlayUiState.removeListener(_onUiStateChanged); WidgetsBinding.instance.removeObserver(this); diff --git a/lib/src/ui/sleuth_theme.dart b/lib/src/ui/sleuth_theme.dart index e77a13ca..7e77a7cf 100644 --- a/lib/src/ui/sleuth_theme.dart +++ b/lib/src/ui/sleuth_theme.dart @@ -42,9 +42,10 @@ import '../models/performance_issue.dart'; /// - **Confidence** (3): `confidenceConfirmed`, `confidenceLikely`, `confidencePossible` /// - **Source accents** (4): left border on issue cards — `sourceVmTimeline`, etc. /// - **Fix effort** (3): `effortQuick`, `effortMedium`, `effortInvolved` -/// - **Surfaces** (9): backgrounds, borders, card states +/// - **Surfaces** (10): backgrounds, borders, the card divider, card states /// - **Text hierarchy** (5): `textPrimary` through `textSubtle` -/// - **Badge pairs** (6): bg + text for VM/FRAME/DBG badges +/// - **Badge pairs** (6): bg + text for the former VM/FRAME/DBG badges, +/// unused by the overlay /// - **Banner pairs** (8): bg + text for debug/instrumentation/success/warning /// - **Causal graph** (1): `effectsBadge` for downstream effects count /// - **Spacing** (6): `spacingXxs` through `spacingXl` @@ -134,7 +135,7 @@ class SleuthThemeData { this.effortInvolved = const Color(0xFFEF4444), // ── Surfaces ── - this.cardBackground = const Color(0xF51E1E2E), + this.cardBackground = const Color(0xFF1E1E2E), this.pageBackground = const Color(0xFF1E1E2E), this.sectionBackground = const Color(0xFF252536), this.aboutBackground = const Color(0xFF111827), @@ -228,7 +229,8 @@ class SleuthThemeData { this.radiusXxl = 12, this.radiusCard = 16, this.radiusFull = 20, - }); + Color? cardDivider, + }) : _cardDivider = cardDivider; /// Explicit dark theme — identical to the default constructor. /// @@ -258,7 +260,7 @@ class SleuthThemeData { sourceStructural: const Color(0xFF6B7280), sourceNone: const Color(0xFF4B5563), // Surfaces - cardBackground: const Color(0xF5FFFFFF), + cardBackground: const Color(0xFFFFFFFF), pageBackground: const Color(0xFFF9FAFB), sectionBackground: const Color(0xFFF3F4F6), aboutBackground: const Color(0xFFE5E7EB), @@ -317,6 +319,7 @@ class SleuthThemeData { textTertiary: const Color(0xFFD1D5DB), textQuaternary: const Color(0xFFD1D5DB), border: const Color(0xFF9CA3AF), + cardDivider: const Color(0xFF9CA3AF), sourceStructural: const Color(0xFFD1D5DB), sourceNone: const Color(0xFF9CA3AF), badgeFillAlpha: 1, @@ -328,12 +331,13 @@ class SleuthThemeData { const SleuthThemeData.highContrastLight() : this( brightness: Brightness.light, - cardBackground: const Color(0xF5FFFFFF), + cardBackground: const Color(0xFFFFFFFF), pageBackground: const Color(0xFFF9FAFB), sectionBackground: const Color(0xFFF3F4F6), aboutBackground: const Color(0xFFE5E7EB), fixHintBackground: const Color(0xFFEFF6FF), border: const Color(0xFF6B7280), + cardDivider: const Color(0xFF6B7280), cardDefault: const Color(0xFFE5E7EB), cardHighlighted: const Color(0xFFDBEAFE), cardJankFlash: const Color(0xFFFEE2E2), @@ -378,11 +382,11 @@ class SleuthThemeData { /// Maps a Material [ColorScheme] onto the overlay's surfaces and text. /// /// `surface` → [pageBackground], `surfaceContainer` → [cardBackground] - /// (alpha 0xF5), `surfaceContainerHigh` → [sectionBackground], + /// (opaque), `surfaceContainerHigh` → [sectionBackground], /// `surfaceContainerHighest` → [cardDefault], `onSurface` → /// [textPrimary], `onSurfaceVariant` → [textSecondary] and /// [textTertiary], `outline` → [textQuaternary], `outlineVariant` → - /// [border], `primary` → [checkboxActive], [guideStepAccent] and + /// [border] and [cardDivider], `primary` → [checkboxActive], [guideStepAccent] and /// [aiChatUserBubbleBg], `onPrimary` → [aiChatUserBubbleText], /// `primaryContainer` → [cardHighlighted], `errorContainer` → /// [cardJankFlash]. Every other token, including the severity, category, @@ -391,8 +395,8 @@ class SleuthThemeData { /// `scheme.surface` (which also sets [brightness]). /// /// Every pair the overlay draws on a surface is checked: text and - /// severity text at 4.5:1 (the translucent [cardBackground] over a black - /// and a white host), badges over their tint, the source accent strip at + /// severity text at 4.5:1 (on [cardBackground], also blended over a black + /// and a white host for a custom translucent card), badges over their tint, the source accent strip at /// 3:1. The text group (primary to quaternary) is tried in order: the /// scheme's, the scheme's with `onSurfaceVariant` for [textQuaternary], /// then the preset's. With each, a scheme surface that fails takes the @@ -435,7 +439,7 @@ class SleuthThemeData { for (final text in textGroups) { final surfaces = <_SchemeSurface, Color>{ _SchemeSurface.page: scheme.surface, - _SchemeSurface.card: scheme.surfaceContainer.withAlpha(0xF5), + _SchemeSurface.card: scheme.surfaceContainer.withAlpha(0xFF), _SchemeSurface.section: scheme.surfaceContainerHigh, _SchemeSurface.cardDefault: scheme.surfaceContainerHighest, _SchemeSurface.highlighted: scheme.primaryContainer, @@ -450,6 +454,7 @@ class SleuthThemeData { cardHighlighted: surfaces[_SchemeSurface.highlighted], cardJankFlash: surfaces[_SchemeSurface.jankFlash], border: scheme.outlineVariant, + cardDivider: scheme.outlineVariant, textPrimary: text[0], textSecondary: text[1], textTertiary: text[2], @@ -518,9 +523,10 @@ class SleuthThemeData { /// the high-contrast presets. final double badgeFillAlpha; - /// Reserved for a keyboard focus indicator: 2 in the high-contrast - /// presets, 0 otherwise. The overlay does not read it; its controls are - /// touch targets without keyboard focus and draw no ring. + /// Reserved for a wider focus indicator: 2 in the high-contrast presets, + /// 0 otherwise. The overlay does not read it. The controls that take + /// keyboard focus, the More button and the More menu's rows, draw a + /// 2 px [checkboxActive] outline in every preset. final double focusRingWidth; /// Width of the source accent on the left edge of an issue card. @@ -573,6 +579,17 @@ class SleuthThemeData { final Color cardHighlighted; final Color cardJankFlash; + /// Line between the card's sections. In the dark preset [border] equals + /// [cardDefault], so a divider drawn in [border] vanishes against a + /// card; this token stays visible. When not set it is 0xFF4B5563 for a + /// dark [brightness] and 0xFFB4BAC4 for a light one. + Color get cardDivider => + _cardDivider ?? + (brightness == Brightness.dark + ? const Color(0xFF4B5563) + : const Color(0xFFB4BAC4)); + final Color? _cardDivider; + // ── Text hierarchy ── final Color textPrimary; final Color textSecondary; @@ -581,11 +598,24 @@ class SleuthThemeData { final Color textSubtle; // ── Badge pairs ── + // The run label in the header replaced the VM+, FRAME and DBG badges. + + /// Unused by the overlay; kept for custom themes. final Color badgeVmBg; + + /// Unused by the overlay; kept for custom themes. final Color badgeVmText; + + /// Unused by the overlay; kept for custom themes. final Color badgeFrameBg; + + /// Unused by the overlay; kept for custom themes. final Color badgeFrameText; + + /// Unused by the overlay; kept for custom themes. final Color badgeDbgBg; + + /// Unused by the overlay; kept for custom themes. final Color badgeDbgText; // ── Banner pairs ── @@ -909,6 +939,7 @@ class SleuthThemeData { Color? effortMedium, Color? effortInvolved, Color? cardBackground, + Color? cardDivider, Color? pageBackground, Color? sectionBackground, Color? aboutBackground, @@ -1009,6 +1040,7 @@ class SleuthThemeData { effortMedium: effortMedium ?? this.effortMedium, effortInvolved: effortInvolved ?? this.effortInvolved, cardBackground: cardBackground ?? this.cardBackground, + cardDivider: cardDivider ?? _cardDivider, pageBackground: pageBackground ?? this.pageBackground, sectionBackground: sectionBackground ?? this.sectionBackground, aboutBackground: aboutBackground ?? this.aboutBackground, diff --git a/test/debug/debug_instrumentation_coordinator_profile_test.dart b/test/debug/debug_instrumentation_coordinator_profile_test.dart index 066b5618..8bca6481 100644 --- a/test/debug/debug_instrumentation_coordinator_profile_test.dart +++ b/test/debug/debug_instrumentation_coordinator_profile_test.dart @@ -186,7 +186,7 @@ void main() { // The overlay's own replacements are dropped. 'SleuthListenableBuilder', '_ToastFade', - '_SeverityChipPill', + '_SeverityChip', '_TriggerLayout', ]) { FlutterTimeline.startSync(name); diff --git a/test/debug/overlay_denylist_audit_test.dart b/test/debug/overlay_denylist_audit_test.dart index f8eced6f..658e5007 100644 --- a/test/debug/overlay_denylist_audit_test.dart +++ b/test/debug/overlay_denylist_audit_test.dart @@ -60,7 +60,6 @@ const _nonWidgetTypes = { 'GlobalKey', 'HttpException', 'InputDecoration', - 'IntTween', 'Interval', 'LinearGradient', 'LinkedHashSet', @@ -74,7 +73,6 @@ const _nonWidgetTypes = { 'RegExp', 'RoundedRectangleBorder', 'ScrollController', - 'Size', 'StringBuffer', 'TextEditingController', 'TextPainter', @@ -344,6 +342,22 @@ final b = Row(children: [Icon(x)]); ); }); + test('the framework section does not grow', () { + final overlayClasses = { + for (final entry in uiSources.entries) + for (final match in _overlayClassRegex.allMatches(entry.value)) + match.group(1)!, + }; + final framework = { + for (final entry + in DebugInstrumentationCoordinator.debugFrameworkWidgetDenyList) + if (!entry.startsWith('_') && !overlayClasses.contains(entry)) entry, + }; + // Each framework widget the overlay uses is one an app's own use of + // can no longer be counted. The overlay may drop entries, never add. + expect(framework.length, lessThanOrEqualTo(52), reason: '$framework'); + }); + test('every framework entry in the denylist still corresponds to a UI ' 'source usage (catches stale entries)', () { // An overlay-widget-class prefix filter: if an entry looks like a diff --git a/test/helpers/overlay_harness.dart b/test/helpers/overlay_harness.dart index 36bff3f7..4f1150ec 100644 --- a/test/helpers/overlay_harness.dart +++ b/test/helpers/overlay_harness.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sleuth/src/controller/sleuth_controller.dart'; import 'package:sleuth/src/models/performance_issue.dart'; +import 'package:sleuth/src/ui/more_menu.dart'; import 'package:sleuth/src/ui/overlay_ui_state.dart'; import 'package:sleuth/src/ui/sleuth_overlay.dart'; @@ -80,6 +81,31 @@ Future openDashboard( await tester.pump(const Duration(milliseconds: 500)); } +/// Opens the card's More menu and lets its fade finish. +Future openMoreMenu(WidgetTester tester) async { + await tester.tap(find.byIcon(Icons.more_horiz)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); +} + +/// The More menu's row labelled [label]. +Finder moreMenuRow(String label) => find.descendant( + of: find.byType(SleuthMoreMenu), + matching: find.text(label), +); + +/// Opens the More menu and taps its [label] row, then lets the page that +/// opens settle. +Future openFromMoreMenu(WidgetTester tester, String label) async { + await openMoreMenu(tester); + // The panel scrolls when the card is short. + await tester.ensureVisible(moreMenuRow(label)); + await tester.pump(); + await tester.tap(moreMenuRow(label)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); +} + /// Sends a system back (Android back button / `didPopRoute`) and pumps. Future systemBack(WidgetTester tester) async { final handled = await tester.binding.handlePopRoute(); diff --git a/test/ui/accessibility_guideline_test.dart b/test/ui/accessibility_guideline_test.dart index c2c3c46d..c1929e09 100644 --- a/test/ui/accessibility_guideline_test.dart +++ b/test/ui/accessibility_guideline_test.dart @@ -8,6 +8,8 @@ import 'package:sleuth/src/controller/sleuth_controller.dart'; import 'package:sleuth/src/models/ai_chat_adapter.dart'; import 'package:sleuth/src/ui/overlay_ui_state.dart'; +import 'package:sleuth/src/ui/more_menu.dart'; + import '../helpers/overlay_harness.dart'; /// 48 x 48 for every tappable node, except the compact card-header @@ -24,7 +26,7 @@ class SleuthTapTargetGuideline extends MinimumTapTargetGuideline { static const compactHeaderLabels = { 'Show overlay', 'Hide overlay', - 'Toggle theme', + 'More', 'Minimize', 'Maximize', 'Restore', @@ -191,6 +193,8 @@ void main() { await _expectGuidelines(tester, 'collapsed'); await _open(tester, find.textContaining('Nested intrinsic layout')); await _expectGuidelines(tester, 'expanded'); + await openMoreMenu(tester); + await _expectGuidelines(tester, 'menu'); handle.dispose(); }); } @@ -218,13 +222,12 @@ void main() { 'encyclopedia', ); await page(find.bySemanticsLabel('Ask AI about this issue'), 'AI chat'); - await page(find.bySemanticsLabel('Guide'), 'guide'); + await openMoreMenu(tester); + await page(moreMenuRow('Guide'), 'guide'); controller.overlayUiState.hide('slow_request'); await tester.pump(); - await page( - find.bySemanticsLabel('1 hidden. Show hidden issues'), - 'hidden issues', - ); + await openMoreMenu(tester); + await page(moreMenuRow('Hidden issues'), 'hidden issues'); handle.dispose(); }); @@ -245,8 +248,8 @@ void main() { } for (final scale in [1.0, 1.3]) { - testWidgets('footer: the Hidden action keeps a 48 x 48 target and its ' - 'whole label on a 220 px card at ${scale}x', (tester) async { + testWidgets('menu: every row keeps a 48 px target and its whole label on ' + 'a 320 px screen at ${scale}x', (tester) async { final handle = tester.ensureSemantics(); tester.view.physicalSize = const Size(320, 568); tester.view.devicePixelRatio = 1; @@ -260,61 +263,53 @@ void main() { ), ); controller.overlayUiState.setCardGeometry( - offset: const Offset(40, 0), - width: 220, + offset: const Offset(0, 0), + width: 320, height: 548, windowState: CardWindowState.normal, ); controller.issuesNotifier.value = mixedOverlayIssues(); controller.overlayUiState.hide('slow_request'); + controller.suppressedCountNotifier.value = 3; await openDashboard(tester, controller); + await openMoreMenu(tester); + expect(tester.takeException(), isNull); final card = tester.getRect( find.byWidgetPredicate((w) => w is Material && w.elevation == 8), ); + final panel = tester.getRect(find.byType(SleuthMoreMenu)); + expect(card.contains(panel.topLeft), isTrue); + expect(card.contains(panel.bottomRight - const Offset(1, 1)), isTrue); - for (final (suppressed, label) in [ - (0, '1 hidden'), - (3, '1 hidden · 3 suppressed'), + for (final label in [ + 'Startup', + 'Rebuild stats', + 'Hidden issues', + 'Encyclopedia', + 'Guide', + 'Copy snapshot', ]) { - controller.suppressedCountNotifier.value = suppressed; + final text = moreMenuRow(label); + await tester.ensureVisible(text); await tester.pump(); - expect(tester.takeException(), isNull, reason: label); - - final action = tester.getRect( - find.bySemanticsLabel('$label. Show hidden issues'), - ); - expect(action.width, greaterThanOrEqualTo(48), reason: label); - expect(action.height, greaterThanOrEqualTo(48), reason: label); - expect(card.contains(action.topLeft), isTrue, reason: label); - expect(card.contains(action.bottomRight - const Offset(1, 1)), isTrue); - - // The whole label is laid out, inside the action. - final text = find.text(label); expect( tester.renderObject(text).didExceedMaxLines, isFalse, reason: label, ); - final textRect = tester.getRect(text); - expect(action.left, lessThanOrEqualTo(textRect.left), reason: label); - expect(action.right, greaterThanOrEqualTo(textRect.right)); - expect(action.top, lessThanOrEqualTo(textRect.top), reason: label); - expect(action.bottom, greaterThanOrEqualTo(textRect.bottom)); - - await expectLater( - tester, - meetsGuideline(_sleuthTapTargetGuideline), - reason: label, - ); - await expectLater( - tester, - meetsGuideline(labeledTapTargetGuideline), - reason: label, + final row = tester.getRect( + find.ancestor(of: text, matching: find.byType(InkWell)).first, ); + expect(row.height, greaterThanOrEqualTo(48), reason: label); + expect(row.width, panel.width, reason: label); } + await expectLater(tester, meetsGuideline(_sleuthTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); - // It still opens the Hidden list. - await tester.tap(find.text('1 hidden · 3 suppressed')); + // The hidden count includes the config's suppressions, and the row + // opens the Hidden list. + expect(find.bySemanticsLabel('Hidden issues, 4'), findsOneWidget); + await tester.tap(moreMenuRow('Hidden issues')); await tester.pump(); await tester.pump(const Duration(seconds: 1)); expect(_routeNodes(tester), 1); diff --git a/test/ui/back_gesture_test.dart b/test/ui/back_gesture_test.dart index 5a580559..507ed55a 100644 --- a/test/ui/back_gesture_test.dart +++ b/test/ui/back_gesture_test.dart @@ -59,7 +59,7 @@ List _recordFrameworkBackRequests(WidgetTester tester) { } Future _openEncyclopedia(WidgetTester tester) async { - await tester.tap(find.bySemanticsLabel('Encyclopedia')); + await openFromMoreMenu(tester, 'Encyclopedia'); await tester.pumpAndSettle(); expect(find.byType(IssueEncyclopediaPage), findsOneWidget); } @@ -116,11 +116,27 @@ void main() { expect(controller.overlayUiState.dashboardOpen, isTrue); }); + testWidgets('More menu open: back closes the menu, then the card', ( + tester, + ) async { + final controller = await pumpOverlay(tester); + await openDashboard(tester, controller); + await openMoreMenu(tester); + + expect(await systemBack(tester), isTrue); + expect(find.byIcon(Icons.more_horiz), findsOneWidget); + expect(moreMenuRow('Guide'), findsNothing); + expect(controller.overlayUiState.dashboardOpen, isTrue); + + expect(await systemBack(tester), isTrue); + expect(controller.overlayUiState.dashboardOpen, isFalse); + }); + testWidgets('Hidden list open: back closes only the list', (tester) async { final controller = await pumpOverlay(tester); controller.overlayUiState.hide('some_issue'); await openDashboard(tester, controller); - await tester.tap(find.text('1 hidden')); + await openFromMoreMenu(tester, 'Hidden issues'); await tester.pumpAndSettle(); expect(find.byType(HiddenIssuesPage), findsOneWidget); @@ -361,7 +377,7 @@ void main() { final controller = await pumpOverlay(tester); await openDashboard(tester, controller); // Drag the card by its title bar. - await tester.drag(find.text('Sleuth'), const Offset(-120, -60)); + await tester.drag(find.byIcon(Icons.pets), const Offset(-120, -60)); await tester.pumpAndSettle(); final moved = controller.overlayUiState.cardOffset; expect(moved, isNotNull); @@ -369,10 +385,10 @@ void main() { await systemBack(tester); await openDashboard(tester, controller); expect(controller.overlayUiState.cardOffset, moved); - final cardTopLeft = tester.getTopLeft(find.text('Sleuth')); + final cardTopLeft = tester.getTopLeft(find.byIcon(Icons.pets)); await systemBack(tester); await openDashboard(tester, controller); - expect(tester.getTopLeft(find.text('Sleuth')), cardTopLeft); + expect(tester.getTopLeft(find.byIcon(Icons.pets)), cardTopLeft); }); }); } diff --git a/test/ui/card_resize_test.dart b/test/ui/card_resize_test.dart index a947a757..49768ff7 100644 --- a/test/ui/card_resize_test.dart +++ b/test/ui/card_resize_test.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sleuth/src/controller/sleuth_controller.dart'; +import 'package:sleuth/src/models/performance_issue.dart'; import 'package:sleuth/src/ui/floating_issues_card.dart'; import 'package:sleuth/src/ui/overlay_ui_state.dart'; @@ -70,11 +72,22 @@ void main() { await tester.pump(); } + /// Labels of the custom actions on the resize grip. + Set resizeActions(WidgetTester tester) => { + for (final id + in tester + .getSemantics(find.bySemanticsLabel('Resize card')) + .getSemanticsData() + .customSemanticsActionIds ?? + const []) + CustomSemanticsAction.getAction(id)!.label, + }; + testWidgets('resize custom actions step by 48 px', (tester) async { final handle = tester.ensureSemantics(); controller.overlayUiState.setCardGeometry( offset: const Offset(100, 100), - width: 300, + width: 360, height: null, windowState: CardWindowState.normal, ); @@ -84,9 +97,13 @@ void main() { expect(tester.getSize(findResizeHandle()), const Size(48, 48)); await performCustomAction(tester, resize, 'Wider'); - expect(controller.overlayUiState.cardWidth, 348); + expect(controller.overlayUiState.cardWidth, 408); + await performCustomAction(tester, resize, 'Narrower'); + expect(controller.overlayUiState.cardWidth, 360); + // Narrower stops at the 320 px minimum and is not offered there. await performCustomAction(tester, resize, 'Narrower'); - expect(controller.overlayUiState.cardWidth, 300); + expect(controller.overlayUiState.cardWidth, 320); + expect(resizeActions(tester), {'Taller', 'Shorter', 'Wider'}); await performCustomAction(tester, resize, 'Taller'); expect(controller.overlayUiState.cardHeight, 330 + 48); await performCustomAction(tester, resize, 'Shorter'); @@ -116,7 +133,7 @@ void main() { await tester.pumpWidget(buildCard()); final initial = findCardConstrainedBox(tester).constraints.maxWidth; - expect(initial, 300.0); // _defaultCardWidth + expect(initial, 320.0); // _defaultCardWidth await dragHandle(tester, const Offset(50, 0)); @@ -126,25 +143,62 @@ void main() { }); testWidgets('dragging handle left decreases card width', (tester) async { + controller.overlayUiState.setCardGeometry( + offset: const Offset(100, 100), + width: 420, + height: null, + windowState: CardWindowState.normal, + ); await tester.pumpWidget(buildCard()); final initial = findCardConstrainedBox(tester).constraints.maxWidth; + expect(initial, 420); - // Large leftward drag — slop adds ~10px right, then -300 overwhelms it. - // Net result should be less than initial (300). - await dragHandle(tester, const Offset(-300, 0)); + // Slop adds 40 px; the 80 px drag left still shrinks the card. + await dragHandle(tester, const Offset(-80, 0)); final shrunk = findCardConstrainedBox(tester).constraints.maxWidth; expect(shrunk, lessThan(initial)); }); - testWidgets('width clamps at minimum (220px)', (tester) async { + testWidgets('Wider is not offered at the widest card', (tester) async { + final handle = tester.ensureSemantics(); + controller.overlayUiState.setCardGeometry( + offset: const Offset(0, 0), + width: 2000, + height: 400, + windowState: CardWindowState.normal, + ); + await tester.pumpWidget(buildCard()); + expect(resizeActions(tester), {'Taller', 'Shorter', 'Narrower'}); + handle.dispose(); + }); + + testWidgets('a stored width below the minimum is raised', (tester) async { + // An older release allowed 220 px. + controller.overlayUiState.setCardGeometry( + offset: const Offset(100, 100), + width: 220, + height: null, + windowState: CardWindowState.normal, + restoreWidth: 230, + ); + await tester.pumpWidget(buildCard()); + expect(findCardConstrainedBox(tester).constraints.maxWidth, 320); + // The value stored is raised on the next commit, not on read. + expect(controller.overlayUiState.cardWidth, 220); + await tester.tap(find.byIcon(Icons.crop_square)); + await tester.pump(); + expect(controller.overlayUiState.restoreWidth, 320); + }); + + testWidgets('width clamps at minimum (320 px)', (tester) async { await tester.pumpWidget(buildCard()); // Drag far left — slop adds 40px but then -500px overwhelms it await dragHandle(tester, const Offset(-500, 0)); final box = findCardConstrainedBox(tester); - expect(box.constraints.maxWidth, 220.0); + expect(box.constraints.maxWidth, 320.0); }); testWidgets('dragging handle down increases card height', (tester) async { @@ -198,7 +252,7 @@ void main() { await tester.tap(find.byIcon(Icons.filter_none)); await tester.pump(); - expect(findCardConstrainedBox(tester).constraints.maxWidth, 300.0); + expect(findCardConstrainedBox(tester).constraints.maxWidth, 320.0); }); }); group('Card geometry in the overlay', () { @@ -228,6 +282,38 @@ void main() { find.byWidgetPredicate((w) => w is Material && w.elevation == 8), ); + testWidgets('on a 300 px screen the minimum is the width less the ' + 'margins, and the card fits', (tester) async { + final controller = await pumpView(tester, const Size(300, 568)); + controller.overlayUiState.setCardGeometry( + offset: null, + width: 200, + height: null, + windowState: CardWindowState.normal, + ); + await openDashboard(tester, controller); + expect(tester.takeException(), isNull); + // 200 is raised to 300 - 2 x 16. + final card = cardRect(tester); + expect(card.width, 268); + expect(card.left, greaterThanOrEqualTo(0)); + expect(card.right, lessThanOrEqualTo(300)); + + // Below 280 px the header has no window controls; a stored + // maximized state still fits. + expect(find.byIcon(Icons.crop_square), findsNothing); + controller.overlayUiState.setCardGeometry( + offset: null, + width: null, + height: null, + windowState: CardWindowState.maximized, + ); + await tester.pump(); + expect(tester.takeException(), isNull); + expect(cardRect(tester).width, 268); + expect(cardRect(tester).left, 16); + }); + testWidgets('a maximized card refits after the screen rotates', ( tester, ) async { @@ -282,23 +368,23 @@ void main() { ui.loadJson({ 'schemaVersion': 1, 'cardOffset': {'dx': 20.0, 'dy': 100.0}, - 'cardWidth': 240.0, + 'cardWidth': 340.0, 'cardHeight': 420.0, 'windowState': 'normal', }); await tester.pump(); - expect(cardRect(tester), const Rect.fromLTWH(20, 100, 240, 420)); + expect(cardRect(tester), const Rect.fromLTWH(20, 100, 340, 420)); - await tester.drag(find.text('Sleuth'), const Offset(0, 150)); + await tester.drag(find.byIcon(Icons.pets), const Offset(0, 150)); await tester.pumpAndSettle(); - expect(ui.cardWidth, 240); + expect(ui.cardWidth, 340); expect(ui.cardHeight, 420); expect(ui.cardOffset!.dx, 20); expect(ui.cardOffset!.dy, inExclusiveRange(100, 250 + 1)); final card = cardRect(tester); expect(card.left, 20); expect(card.top, ui.cardOffset!.dy); - expect(card.width, 240); + expect(card.width, 340); }); /// Opens the dashboard with a normal card of [height] at [offset]. @@ -327,13 +413,20 @@ void main() { Finder header() => find.bySemanticsLabel('Sleuth'); Finder grip() => find.bySemanticsLabel('Resize card'); - /// The card, its footer and its resize grip lie inside the view above + /// The grip's dots, drawn in both window states. + Finder gripDots() => find.byWidgetPredicate( + (w) => + w is CustomPaint && + w.painter.runtimeType.toString() == '_CornerGripPainter', + ); + + /// The card and its resize grip lie inside the view above /// [usableBottom]. void expectCardOnScreen(WidgetTester tester, double usableBottom) { final card = cardRect(tester); expect(card.top, greaterThanOrEqualTo(0)); expect(card.bottom, lessThanOrEqualTo(usableBottom)); - for (final finder in [find.bySemanticsLabel('Guide'), grip()]) { + for (final finder in [gripDots()]) { final rect = tester.getRect(finder); expect(rect.top, greaterThanOrEqualTo(0)); expect(rect.bottom, lessThanOrEqualTo(usableBottom)); @@ -341,9 +434,7 @@ void main() { } } - testWidgets('Move down keeps the footer and grip on screen', ( - tester, - ) async { + testWidgets('Move down keeps the grip on screen', (tester) async { final handle = tester.ensureSemantics(); final controller = await pumpCard(tester); for (var i = 0; i < 5; i++) { @@ -408,7 +499,7 @@ void main() { final handle = tester.ensureSemantics(); final controller = await pumpCard(tester); final gesture = await tester.startGesture( - tester.getCenter(find.text('Sleuth')), + tester.getCenter(find.byIcon(Icons.pets)), ); for (var i = 0; i < 10; i++) { await gesture.moveBy(const Offset(0, 50)); @@ -434,17 +525,55 @@ void main() { final data = tester.getSemantics(header()).getSemanticsData(); expect(data.customSemanticsActionIds ?? const [], isEmpty); - await tester.drag(find.text('Sleuth'), const Offset(0, 120)); + await tester.drag(find.byIcon(Icons.pets), const Offset(0, 120)); await tester.pumpAndSettle(); expect(cardRect(tester), before); + // The grip draws its dots only: no resize actions. + expect(grip(), findsNothing); + expect(gripDots(), findsOneWidget); expect(controller.overlayUiState.cardOffset, stored); expectCardOnScreen(tester, 568); handle.dispose(); }); + testWidgets('a maximized grip passes drags to the list', (tester) async { + final controller = await pumpCard( + tester, + windowState: CardWindowState.maximized, + ); + controller.issuesNotifier.value = [ + for (final issue in mixedOverlayIssues()) issue, + for (var i = 0; i < 12; i++) + PerformanceIssue( + severity: IssueSeverity.warning, + category: IssueCategory.build, + confidence: IssueConfidence.confirmed, + title: 'Filler $i', + detail: 'Detail', + fixHint: 'Fix', + stableId: 'filler_$i', + ), + ]; + await tester.pump(); + final before = cardRect(tester); + final list = find.byType(Scrollable).last; + final offset = tester.state(list).position.pixels; + + await tester.dragFrom( + tester.getCenter(gripDots()), + const Offset(0, -120), + ); + await tester.pumpAndSettle(); + expect(cardRect(tester), before); + expect( + tester.state(list).position.pixels, + greaterThan(offset), + ); + }); + for (final keyboard in [150.0, 300.0]) { testWidgets('a maximized card above a $keyboard px keyboard keeps a ' - 'list under the summary bar', (tester) async { + 'list under the status row', (tester) async { final controller = await pumpView( tester, const Size(844, 390), @@ -459,15 +588,9 @@ void main() { await openDashboard(tester, controller); expect(tester.takeException(), isNull); + // The card keeps a row of the list under the status row. final list = tester.getRect(find.byType(ListView)); - expect(list.height, greaterThan(0)); - // The summary bar's 36 px sit over the top of the list's area, - // which keeps at least the chips' 48 px hit height. - expect(list.height + 36, greaterThanOrEqualTo(48)); - if (keyboard == 150) { - // Room for the bar and a row: the banners give it to the list. - expect(list.height, greaterThanOrEqualTo(48)); - } + expect(list.height, greaterThanOrEqualTo(48)); }); } }); diff --git a/test/ui/dashboard_dbg_badge_test.dart b/test/ui/dashboard_dbg_badge_test.dart deleted file mode 100644 index 3c749136..00000000 --- a/test/ui/dashboard_dbg_badge_test.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:sleuth/src/controller/sleuth_controller.dart'; -import 'package:sleuth/src/ui/floating_issues_card.dart'; - -void main() { - group('DBG badge', () { - late SleuthController controller; - - setUp(() { - controller = SleuthController(); - controller.initializeDetectorsForTest(); - }); - - tearDown(() { - controller.dispose(); - }); - - testWidgets('DBG badge is absent when enableDebugCallbacks is false', ( - tester, - ) async { - // Default config has enableDebugCallbacks = false - expect(controller.isDebugCallbacksActive, isFalse); - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: FloatingIssuesCard(controller: controller, onClose: () {}), - ), - ), - ); - - // DBG text should not be in the widget tree - expect(find.text('DBG'), findsNothing); - }); - - testWidgets('VM+/FRAME badge is always present', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: FloatingIssuesCard(controller: controller, onClose: () {}), - ), - ), - ); - - // FRAME badge should be visible (not connected to VM) - expect(find.text('FRAME'), findsOneWidget); - }); - - testWidgets('DBG badge is present when enableDebugCallbacks is true', ( - tester, - ) async { - final dbgController = SleuthController( - config: const SleuthConfig(enableDebugCallbacks: true), - ); - dbgController.initializeDetectorsForTest(); - - expect(dbgController.isDebugCallbacksActive, isTrue); - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: FloatingIssuesCard(controller: dbgController, onClose: () {}), - ), - ), - ); - - expect(find.text('DBG'), findsOneWidget); - - dbgController.dispose(); - }); - - test('isDebugCallbacksActive returns false by default', () { - expect(controller.isDebugCallbacksActive, isFalse); - }); - - test('kDebugMode is true in test environment', () { - // Confirms that the only missing condition for DBG badge is - // isDebugCallbacksActive, not kDebugMode. - expect(kDebugMode, isTrue); - }); - }); -} diff --git a/test/ui/dashboard_export_test.dart b/test/ui/dashboard_export_test.dart index 30debdf4..9dc3c1d3 100644 --- a/test/ui/dashboard_export_test.dart +++ b/test/ui/dashboard_export_test.dart @@ -7,6 +7,8 @@ import 'package:sleuth/src/controller/sleuth_controller.dart'; import 'package:sleuth/src/ui/floating_issues_card.dart'; import 'package:sleuth/src/utils/own_work_log.dart'; +import '../helpers/overlay_harness.dart'; + void main() { group('FloatingIssuesCard export', () { late SleuthController controller; @@ -20,6 +22,15 @@ void main() { controller.dispose(); }); + /// Taps Copy snapshot in the More menu. + Future copySnapshot(WidgetTester tester) async { + await openMoreMenu(tester); + await tester.ensureVisible(moreMenuRow('Copy snapshot')); + await tester.pump(); + await tester.tap(moreMenuRow('Copy snapshot')); + await tester.pump(); + } + Widget buildCard() { return MaterialApp( home: Scaffold( @@ -28,10 +39,11 @@ void main() { ); } - testWidgets('export button is present in header', (tester) async { + testWidgets('Copy snapshot is in the More menu', (tester) async { await tester.pumpWidget(buildCard()); - - expect(find.byIcon(Icons.ios_share), findsOneWidget); + expect(find.byIcon(Icons.ios_share), findsNothing); + await openMoreMenu(tester); + expect(moreMenuRow('Copy snapshot'), findsOneWidget); }); testWidgets('tapping export copies JSON to clipboard', (tester) async { @@ -49,8 +61,7 @@ void main() { ); await tester.pumpWidget(buildCard()); - await tester.tap(find.byIcon(Icons.ios_share)); - await tester.pump(); + await copySnapshot(tester); expect(clipboardContent, isNotNull); expect(clipboardContent, contains('"capturedFrames"')); @@ -68,8 +79,7 @@ void main() { await tester.pumpWidget(buildCard()); final before = OwnWorkLog.now(); - await tester.tap(find.byIcon(Icons.ios_share)); - await tester.pump(); + await copySnapshot(tester); expect(ownWorkLog.overlapUs(before, OwnWorkLog.now()), greaterThan(0)); await tester.pump(const Duration(seconds: 3)); }); @@ -88,10 +98,8 @@ void main() { }, ); await tester.pumpWidget(buildCard()); - await tester.tap(find.byIcon(Icons.ios_share)); - await tester.pump(); - await tester.tap(find.byIcon(Icons.ios_share)); - await tester.pump(); + await copySnapshot(tester); + await copySnapshot(tester); expect(copies, 1); release.complete(); @@ -100,12 +108,39 @@ void main() { expect(find.text('Snapshot copied to clipboard'), findsOneWidget); // The copy ended, so a new tap copies again. await tester.pump(const Duration(seconds: 3)); - await tester.tap(find.byIcon(Icons.ios_share)); - await tester.pump(); + await copySnapshot(tester); expect(copies, 2); await tester.pump(const Duration(seconds: 3)); }); + testWidgets('a failed copy offers Try again, which copies', (tester) async { + var fail = true; + var copies = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + if (fail) throw PlatformException(code: 'denied'); + copies++; + } + return null; + }, + ); + + await tester.pumpWidget(buildCard()); + await copySnapshot(tester); + await tester.pump(); + expect(find.text("Couldn't copy snapshot"), findsOneWidget); + + fail = false; + await tester.tap(find.text('Try again')); + await tester.pump(); + await tester.pump(); + expect(copies, 1); + expect(find.text('Snapshot copied to clipboard'), findsOneWidget); + await tester.pump(const Duration(seconds: 15)); + }); + testWidgets('feedback banner appears on export tap', (tester) async { tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( SystemChannels.platform, @@ -113,8 +148,7 @@ void main() { ); await tester.pumpWidget(buildCard()); - await tester.tap(find.byIcon(Icons.ios_share)); - await tester.pump(); + await copySnapshot(tester); expect(find.text('Snapshot copied to clipboard'), findsOneWidget); @@ -129,8 +163,7 @@ void main() { ); await tester.pumpWidget(buildCard()); - await tester.tap(find.byIcon(Icons.ios_share)); - await tester.pump(); + await copySnapshot(tester); expect(find.text('Snapshot copied to clipboard'), findsOneWidget); // Advance past the 2-second delay and the 200 ms fade-out. diff --git a/test/ui/dashboard_run_label_test.dart b/test/ui/dashboard_run_label_test.dart new file mode 100644 index 00000000..95b4c181 --- /dev/null +++ b/test/ui/dashboard_run_label_test.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sleuth/src/controller/sleuth_controller.dart'; +import 'package:sleuth/src/ui/floating_issues_card.dart'; +import 'package:sleuth/src/ui/overlay_ui_state.dart'; +import 'package:sleuth/src/ui/sleuth_theme.dart'; + +void main() { + group('Run label', () { + late SleuthController controller; + + setUp(() { + controller = SleuthController(); + controller.initializeDetectorsForTest(); + }); + + tearDown(() { + controller.dispose(); + }); + + Future pumpCard( + WidgetTester tester, { + double width = 320, + bool debug = true, + SleuthController? card, + double textScale = 1, + }) async { + tester.view.physicalSize = const Size(800, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final c = card ?? controller; + c.overlayUiState.setCardGeometry( + offset: Offset.zero, + width: width, + height: 400, + windowState: CardWindowState.normal, + ); + await tester.pumpWidget( + MaterialApp( + home: MediaQuery.withClampedTextScaling( + minScaleFactor: textScale, + maxScaleFactor: textScale, + child: Scaffold( + body: FloatingIssuesCard( + controller: c, + onClose: () {}, + isDebugMode: debug, + ), + ), + ), + ), + ); + } + + /// How far the header stepped down: 0 name and full label, 1 full + /// label, 2 mode only, 3 dot only. + int step() { + if (find.text('Debug · no VM').evaluate().isNotEmpty) { + return find.text('Sleuth').evaluate().isNotEmpty ? 0 : 1; + } + return find.text('Debug').evaluate().isNotEmpty ? 2 : 3; + } + + testWidgets('replaces the FRAME, VM+ and DBG badges', (tester) async { + final dbg = SleuthController( + config: const SleuthConfig(enableDebugCallbacks: true), + ); + dbg.initializeDetectorsForTest(); + expect(dbg.isDebugCallbacksActive, isTrue); + await pumpCard(tester, card: dbg); + + expect(find.text('FRAME'), findsNothing); + expect(find.text('VM+'), findsNothing); + expect(find.text('DBG'), findsNothing); + expect(find.bySemanticsLabel('Debug run, no VM link'), findsOneWidget); + + // Disposed in the body: debug callbacks set render debug variables, + // which the binding checks before tear-downs run. + await tester.pumpWidget(const SizedBox()); + dbg.dispose(); + }); + + testWidgets('names the mode and the VM link on a wide card', ( + tester, + ) async { + await pumpCard(tester, width: 600); + expect(find.text('Sleuth'), findsOneWidget); + expect(find.text('Debug · no VM'), findsOneWidget); + + controller.vmConnectedNotifier.value = true; + await tester.pump(); + expect(find.text('Debug · VM'), findsOneWidget); + expect(find.bySemanticsLabel('Debug run, VM connected'), findsOneWidget); + }); + + testWidgets('the dot is filled with the VM and a ring without it', ( + tester, + ) async { + BoxDecoration dot() => tester + .widgetList( + find.descendant( + of: find.bySemanticsLabel(RegExp('run, ')), + matching: find.byType(DecoratedBox), + ), + ) + .map((d) => d.decoration as BoxDecoration) + .firstWhere((d) => d.shape == BoxShape.circle); + const theme = SleuthThemeData(); + // The 320 px card in the test font shows the short label, where + // the dot alone carries the VM state. + await pumpCard(tester); + expect(dot().color, isNull); + expect((dot().border! as Border).top.color, theme.severityWarningText); + + controller.vmConnectedNotifier.value = true; + await tester.pump(); + expect(dot().color, theme.severityOkText); + expect(dot().border, isNull); + }); + + testWidgets('reads Profile outside debug mode', (tester) async { + await pumpCard(tester, width: 600, debug: false); + expect(find.text('Profile · no VM'), findsOneWidget); + expect(find.bySemanticsLabel('Profile run, no VM link'), findsOneWidget); + }); + + testWidgets('drops the name, then the VM text, before it overflows', ( + tester, + ) async { + await pumpCard(tester); + // Five header buttons leave little room at 320 px in the test font. + expect(find.text('Sleuth'), findsNothing); + expect(find.text('Debug · no VM'), findsNothing); + // The label keeps its full reading for a screen reader. + expect(find.bySemanticsLabel('Debug run, no VM link'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('steps down by measurement at 1.0x and 1.3x, never later ' + 'at a larger scale', (tester) async { + for (final width in [320.0, 420.0, 520.0, 640.0]) { + final steps = []; + for (final scale in [1.0, 1.3, 2.0]) { + await pumpCard(tester, width: width, textScale: scale); + expect(tester.takeException(), isNull, reason: '$width at $scale'); + steps.add(step()); + } + expect(steps, orderedEquals([...steps]..sort()), reason: '$width'); + // The header clamps at 1.3x, so 2.0x steps as 1.3x does. + expect(steps[2], steps[1], reason: '$width'); + } + // A wide card at 1.0x shows everything; a 320 px card at 1.3x + // keeps at least the dot. + await pumpCard(tester, width: 640); + expect(step(), 0); + await pumpCard(tester, textScale: 1.3); + expect(step(), inInclusiveRange(1, 3)); + expect(find.bySemanticsLabel('Debug run, no VM link'), findsOneWidget); + }); + }); +} diff --git a/test/ui/dashboard_summary_test.dart b/test/ui/dashboard_summary_test.dart index 7ba4ae8e..259840a8 100644 --- a/test/ui/dashboard_summary_test.dart +++ b/test/ui/dashboard_summary_test.dart @@ -22,7 +22,7 @@ PerformanceIssue _testIssue({ } void main() { - group('Issues summary bar', () { + group('Status row chips', () { late SleuthController controller; setUp(() { @@ -42,27 +42,15 @@ void main() { ); } - testWidgets('no issues — summary bar not rendered', (tester) async { + testWidgets('no issues: no severity chips', (tester) async { await tester.pumpWidget(buildCard()); - // Empty state shown expect(find.textContaining('No issues detected'), findsOneWidget); - // "confirmed" text from summary bar should not be present - expect(find.textContaining('confirmed'), findsNothing); + expect(find.textContaining('critical'), findsNothing); + expect(find.textContaining('warning'), findsNothing); }); - testWidgets('issues present — summary bar rendered', (tester) async { - controller.issuesNotifier.value = [ - _testIssue(severity: IssueSeverity.critical, title: 'Issue 1'), - _testIssue(severity: IssueSeverity.warning, title: 'Issue 2'), - ]; - - await tester.pumpWidget(buildCard()); - - expect(find.textContaining('confirmed'), findsOneWidget); - }); - - testWidgets('severity counts are correct', (tester) async { + testWidgets('chips name each severity with its count', (tester) async { controller.issuesNotifier.value = [ _testIssue(severity: IssueSeverity.critical, title: 'C1'), _testIssue(severity: IssueSeverity.critical, title: 'C2'), @@ -71,65 +59,41 @@ void main() { await tester.pumpWidget(buildCard()); - // Find the severity count texts in the summary bar. - // Critical count = 2, warning count = 1 - final texts = tester.widgetList(find.byType(Text)); - final countTexts = texts - .where( - (t) => - t.style?.fontSize == 10 && - t.style?.fontWeight == FontWeight.w600, - ) - .map((t) => t.data) - .toList(); - - expect(countTexts, contains('2')); // 2 critical - expect(countTexts, contains('1')); // 1 warning + expect(find.text('2 critical'), findsOneWidget); + expect(find.text('1 warning'), findsOneWidget); + // A severity with no cards has no chip while it is on. + expect(find.textContaining(' ok'), findsNothing); }); - testWidgets('evidence quality shows confirmed and heuristic counts', ( - tester, - ) async { + testWidgets('no confidence caption under the chips', (tester) async { controller.issuesNotifier.value = [ - _testIssue(confidence: IssueConfidence.confirmed, title: 'Confirmed 1'), - _testIssue(confidence: IssueConfidence.confirmed, title: 'Confirmed 2'), - _testIssue(confidence: IssueConfidence.possible, title: 'Possible 1'), + _testIssue(confidence: IssueConfidence.confirmed, title: 'Confirmed'), + _testIssue(confidence: IssueConfidence.possible, title: 'Possible'), ]; await tester.pumpWidget(buildCard()); - expect(find.textContaining('2 confirmed'), findsOneWidget); - expect(find.textContaining('1 heuristic'), findsOneWidget); + expect(find.textContaining('confirmed'), findsNothing); + expect(find.textContaining('heuristic'), findsNothing); }); - testWidgets('summary bar updates when issues change', (tester) async { + testWidgets('chips follow issue changes', (tester) async { controller.issuesNotifier.value = [ _testIssue(severity: IssueSeverity.warning, title: 'W1'), ]; await tester.pumpWidget(buildCard()); + expect(find.text('1 warning'), findsOneWidget); + expect(find.textContaining('critical'), findsNothing); - // Initially 1 warning - final textsInitial = tester.widgetList(find.byType(Text)); - final warningCounts = textsInitial - .where( - (t) => - t.data == '1' && - t.style?.fontSize == 10 && - t.style?.fontWeight == FontWeight.w600, - ) - .toList(); - expect(warningCounts, isNotEmpty); - - // Add a critical issue controller.issuesNotifier.value = [ _testIssue(severity: IssueSeverity.warning, title: 'W1'), _testIssue(severity: IssueSeverity.critical, title: 'C1'), ]; await tester.pump(); - // Now should show both counts - expect(find.textContaining('2 confirmed'), findsOneWidget); + expect(find.text('1 warning'), findsOneWidget); + expect(find.text('1 critical'), findsOneWidget); }); }); } diff --git a/test/ui/duplicate_stable_id_test.dart b/test/ui/duplicate_stable_id_test.dart index 4df5ee60..dd854f43 100644 --- a/test/ui/duplicate_stable_id_test.dart +++ b/test/ui/duplicate_stable_id_test.dart @@ -6,6 +6,8 @@ import 'package:sleuth/src/models/widget_highlight.dart'; import 'package:sleuth/src/ui/floating_issues_card.dart'; import 'package:sleuth/src/ui/issue_card.dart'; +import '../helpers/overlay_harness.dart' show openMoreMenu; + /// An issue with detector id [id] reported on [widget]; the title names /// the widget so the two cards can be told apart. PerformanceIssue _issue( @@ -136,9 +138,8 @@ void main() { expect(find.text('Title B'), findsOneWidget); }); - testWidgets('hiding one leaves the other visible; footer reads 1 hidden', ( - tester, - ) async { + testWidgets('hiding one leaves the other visible; the More menu counts ' + '1 hidden', (tester) async { controller.issuesNotifier.value = [ _issue('dup', widget: 'A'), _issue('dup', widget: 'B'), @@ -150,8 +151,9 @@ void main() { expect(tester.takeException(), isNull); expect(find.text('Title A'), findsNothing); expect(find.text('Title B'), findsOneWidget); - expect(find.text('1 hidden'), findsOneWidget); expect(controller.overlayUiState.hiddenKeys, {'dup|A'}); + await openMoreMenu(tester); + expect(find.bySemanticsLabel('Hidden issues, 1'), findsOneWidget); await drainToasts(tester); }); diff --git a/test/ui/floating_issues_card_test.dart b/test/ui/floating_issues_card_test.dart index 8f9b1655..833ef6be 100644 --- a/test/ui/floating_issues_card_test.dart +++ b/test/ui/floating_issues_card_test.dart @@ -2,8 +2,17 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:sleuth/sleuth.dart' show Sleuth, StartupMetrics; +import 'package:sleuth/src/ui/focus_ink_well.dart'; +import 'package:sleuth/src/ui/more_menu.dart'; +import 'package:sleuth/src/ui/startup_metrics_page.dart'; +import 'package:sleuth/src/ui/hidden_issues_page.dart'; +import 'package:sleuth/src/ui/issue_encyclopedia_page.dart'; +import 'package:sleuth/src/ui/guide_page.dart'; +import 'package:sleuth/src/ui/issue_card.dart'; import 'package:sleuth/src/controller/sleuth_controller.dart'; import 'package:sleuth/src/debug/debug_instrumentation_coordinator.dart'; import 'package:sleuth/src/debug/debug_snapshot.dart'; @@ -16,7 +25,6 @@ import 'package:sleuth/src/ui/overlay_ui_state.dart'; import 'package:sleuth/src/ui/rebuild_stats_page.dart'; import 'package:sleuth/src/ui/sleuth_theme.dart'; -import '../helpers/contrast_helpers.dart'; import '../helpers/overlay_harness.dart'; PerformanceIssue _pinIssue({ @@ -142,8 +150,8 @@ void main() { // Body content should be gone expect(find.textContaining('No issues detected'), findsNothing); - // Header title should still be there - expect(find.text('Sleuth'), findsOneWidget); + // The header stays: its paw and run label. + expect(find.byIcon(Icons.pets), findsOneWidget); // Restore button should appear (replaces minimize/maximize) expect(find.byIcon(Icons.filter_none), findsOneWidget); expect(find.byIcon(Icons.minimize), findsNothing); @@ -325,30 +333,9 @@ void main() { }); }); - group('Rebuild stats banner (always-on entry point)', () { - // The banner is the SOLE data-discovery path for rebuild stats since - // v0.15.2 — the previous `rebuild_hotspot_summary` rollup IssueCard - // (and the always-on chip above the issue list) was removed in - // v0.15.2 because (a) the panel covers both the data and the signal, - // (b) an always-pinned IssueCard collided with issue ranker reorders, - // and (c) profile-mode counts include first builds (inflations), which - // made route entry look like a warning storm in the issues list. - // - // The panel must therefore render whenever the active RouteSession - // has rebuild attribution, regardless of volume, so low-volume routes - // are still reachable through `RebuildStatsPage` via the inline - // "See all N →" drilldown link. - // - // Tests use the fake-coordinator + scanTreeFullPathForTest pattern - // from `test/controller/rebuild_stats_scan_test.dart` to inject - // synthetic `flutterTimeline`-source counts through the real - // `_scanTreeInner` drain → merge path. The first scan creates the - // active session and drops merged counts (no session at merge time); - // the second scan then merges into the now-active session. - - // Lightweight pump for banner tests — the scan loop needs a Scaffold - // above the floating card for `_findVisiblePageContext` to succeed. - Widget pumpCardForBanner(SleuthController c, {SleuthThemeData? theme}) { + group('More menu', () { + // Scans need a Scaffold above the card for `_findVisiblePageContext`. + Widget pumpMenuCard(SleuthController c, {VoidCallback? onAppTap}) { final card = FloatingIssuesCard( controller: c, onClose: () {}, @@ -356,22 +343,42 @@ void main() { ); return MaterialApp( home: Scaffold( - body: theme == null ? card : SleuthTheme(data: theme, child: card), + body: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onAppTap, + ), + ), + card, + ], + ), ), ); } - Future primeAndMergeCounts( + SleuthController scanningController() { + controller.dispose(); + final c = SleuthController( + config: const SleuthConfig( + treeScanInterval: Duration(seconds: 1), + enabledDetectors: {DetectorType.frameTiming}, + ), + )..initializeDetectorsForTest(); + controller = c; + return c; + } + + /// The first scan creates the active route session; the second merges + /// [counts] into it. + Future mergeCounts( WidgetTester tester, SleuthController c, _FakeCoordinator fake, Map counts, ) async { - // First scan — creates the active route session. Merge runs before - // route detection, so any counts on this scan land on a `null` - // session and are dropped. Feed an empty snapshot. c.scanTreeFullPathForTest(tester.element(find.byType(MaterialApp))); - // Second scan merges synthetic counts into the now-active session. fake.nextSnapshot = DebugSnapshot( rebuildCounts: counts, totalPaintCount: 0, @@ -382,494 +389,364 @@ void main() { await tester.pump(); } - testWidgets( - 'banner is hidden when active session has zero rebuild counts', - (tester) async { - controller.dispose(); - controller = SleuthController( - config: const SleuthConfig( - treeScanInterval: Duration(seconds: 1), - enabledDetectors: {DetectorType.frameTiming}, - ), - ); - controller.initializeDetectorsForTest(); - controller.debugCoordinatorForTest = _FakeCoordinator(); - - await tester.pumpWidget(pumpCardForBanner(controller)); - await tester.pumpAndSettle(); + testWidgets('empty rows read as such and are disabled', (tester) async { + await tester.pumpWidget(pumpMenuCard(controller)); + await openMoreMenu(tester); - // No scan run → no active session → banner hidden. - expect(find.byIcon(Icons.repeat), findsNothing); - expect(find.textContaining('Rebuilds:'), findsNothing); - }, - ); - - testWidgets( - 'banner appears with total + widget count once attribution lands', - (tester) async { - controller.dispose(); - controller = SleuthController( - config: const SleuthConfig( - treeScanInterval: Duration(seconds: 1), - enabledDetectors: {DetectorType.frameTiming}, - ), - ); - controller.initializeDetectorsForTest(); - final fake = _FakeCoordinator(); - controller.debugCoordinatorForTest = fake; - - await tester.pumpWidget(pumpCardForBanner(controller)); - await tester.pumpAndSettle(); - - await primeAndMergeCounts(tester, controller, fake, { - 'ProductCard': 12, - 'PriceTag': 5, - }); - - expect(find.byIcon(Icons.repeat), findsOneWidget); - // 17 total across 2 widget types — the chip is the data-discovery - // path so it surfaces both numbers, not just the headline total. - expect(find.text('Rebuilds: 17 across 2 widgets'), findsOneWidget); - }, - ); - - testWidgets( - 'banner uses singular "widget" when only one widget type has counts', - (tester) async { - controller.dispose(); - controller = SleuthController( - config: const SleuthConfig( - treeScanInterval: Duration(seconds: 1), - enabledDetectors: {DetectorType.frameTiming}, - ), - ); - controller.initializeDetectorsForTest(); - final fake = _FakeCoordinator(); - controller.debugCoordinatorForTest = fake; - - await tester.pumpWidget(pumpCardForBanner(controller)); - await tester.pumpAndSettle(); - - await primeAndMergeCounts(tester, controller, fake, {'SoloCard': 3}); - - expect(find.text('Rebuilds: 3 across 1 widget'), findsOneWidget); - }, - ); - - testWidgets('banner renders for low-volume routes (always-on contract)', ( - tester, - ) async { - // v0.15.2 always-on contract: the panel must render whenever the - // active session has ANY rebuild attribution, no matter how small. - // Pre-v0.15.2 the `rebuild_hotspot_summary` rollup IssueCard was - // gated on a sustained rate (≥ 20 builds/sec + ≥ 30 total + ≥ 1.5s - // duration), so a handful of rebuilds on a quiet route had no UI - // surface at all. The inline panel replaced that gating: it shows - // up the moment counts exist, and the drilldown link is the only - // entry point users have for low-volume routes. This test pins - // that "no minimum" contract so a future rate-floor regression - // can't silently re-introduce a discoverability gap. - controller.dispose(); - controller = SleuthController( - config: const SleuthConfig( - treeScanInterval: Duration(seconds: 1), - enabledDetectors: {DetectorType.frameTiming}, - ), + expect( + find.bySemanticsLabel('Startup, Not measured yet'), + findsOneWidget, ); - controller.initializeDetectorsForTest(); + expect( + find.bySemanticsLabel('Rebuild stats, No counts yet'), + findsOneWidget, + ); + // No hidden issues: no row. + expect(moreMenuRow('Hidden issues'), findsNothing); + await tester.tap(moreMenuRow('Startup')); + await tester.pump(); + expect(find.byType(StartupMetricsPage), findsNothing); + expect(find.byType(SleuthMoreMenu), findsOneWidget); + }); + + testWidgets('Rebuild stats shows the total read at open and opens its ' + 'page on that snapshot', (tester) async { + final c = scanningController(); final fake = _FakeCoordinator(); - controller.debugCoordinatorForTest = fake; + c.debugCoordinatorForTest = fake; + await tester.pumpWidget(pumpMenuCard(c)); + await mergeCounts(tester, c, fake, { + 'ProductCard': 2000, + 'PriceTag': 389, + }); - await tester.pumpWidget(pumpCardForBanner(controller)); - await tester.pumpAndSettle(); + await openMoreMenu(tester); + expect(find.bySemanticsLabel('Rebuild stats, 2,389'), findsOneWidget); - // 4 rebuilds total — pre-v0.15.2 this was below every rollup gate - // (rate, count, and duration). The panel must still render. - await primeAndMergeCounts(tester, controller, fake, {'TinyCard': 4}); + // A scan while the menu is open leaves its value alone. + fake.nextSnapshot = const DebugSnapshot( + rebuildCounts: {'ProductCard': 11}, + totalPaintCount: 0, + elapsed: Duration(milliseconds: 500), + source: RebuildCountSource.flutterTimeline, + ); + c.scanTreeFullPathForTest(tester.element(find.byType(MaterialApp))); + await tester.pump(); + expect(find.bySemanticsLabel('Rebuild stats, 2,389'), findsOneWidget); - expect(find.text('Rebuilds: 4 across 1 widget'), findsOneWidget); + await tester.tap(moreMenuRow('Rebuild stats')); + await tester.pump(); + expect(find.byType(SleuthMoreMenu), findsNothing); + expect(find.byType(RebuildStatsPage), findsOneWidget); }); - testWidgets('the header row is at least 48 px tall', (tester) async { - controller.dispose(); - controller = SleuthController( - config: const SleuthConfig( - treeScanInterval: Duration(seconds: 1), - enabledDetectors: {DetectorType.frameTiming}, - ), + testWidgets('Startup shows TTFF and opens Startup metrics', (tester) async { + addTearDown(Sleuth.resetStartupForTest); + Sleuth.setStartupMetricsForTest( + StartupMetrics(dartEntryTimestamp: DateTime(2026), ttffMs: 200.4), ); - controller.initializeDetectorsForTest(); - final fake = _FakeCoordinator(); - controller.debugCoordinatorForTest = fake; - - await tester.pumpWidget(pumpCardForBanner(controller)); - await tester.pumpAndSettle(); - await primeAndMergeCounts(tester, controller, fake, {'TinyCard': 4}); - - final header = find.bySemanticsLabel( - RegExp(r'^Rebuilds: 4 across 1 widget'), + await tester.pumpWidget(pumpMenuCard(controller)); + await openMoreMenu(tester); + // Shown as "TTFF 200 ms", read out in words. + expect(find.text('TTFF 200 ms'), findsOneWidget); + expect( + find.bySemanticsLabel('Startup, time to first frame 200 milliseconds'), + findsOneWidget, ); - expect(tester.getSize(header).height, greaterThanOrEqualTo(48)); + await tester.tap(moreMenuRow('Startup')); + await tester.pump(); + expect(find.byType(StartupMetricsPage), findsOneWidget); }); - testWidgets('the collapsed pause indicator is not announced twice', ( + testWidgets('Hidden issues appears with a count and opens the list', ( tester, ) async { - controller.dispose(); - controller = SleuthController( - config: const SleuthConfig( - treeScanInterval: Duration(seconds: 1), - enabledDetectors: {DetectorType.frameTiming}, - ), - ); - controller.initializeDetectorsForTest(); - final fake = _FakeCoordinator(); - controller.debugCoordinatorForTest = fake; - - await tester.pumpWidget(pumpCardForBanner(controller)); - await tester.pumpAndSettle(); - await primeAndMergeCounts(tester, controller, fake, {'TinyCard': 4}); - - final header = find.text('Rebuilds: 4 across 1 widget'); - await tester.ensureVisible(header); - await tester.tap(header); - await tester.pump(); - await tester.ensureVisible(find.byIcon(Icons.pause)); - await tester.tap(find.byIcon(Icons.pause)); + controller.overlayUiState + ..hide('a') + ..hide('b'); + await tester.pumpWidget(pumpMenuCard(controller)); + await openMoreMenu(tester); + expect(find.bySemanticsLabel('Hidden issues, 2'), findsOneWidget); + await tester.ensureVisible(moreMenuRow('Hidden issues')); + await tester.tap(moreMenuRow('Hidden issues')); await tester.pump(); - await tester.tap(header); - await tester.pump(); - - // Collapsed and paused: the header label says so; the icon is - // silent. - expect( - find.bySemanticsLabel(RegExp(r'paused, collapsed')), - findsOneWidget, - ); - expect( - tester.widget(find.byIcon(Icons.pause)).semanticLabel, - isNull, - ); + expect(find.byType(HiddenIssuesPage), findsOneWidget); }); - for (final (name, theme) in [ - ('dark', const SleuthThemeData()), - ('light', const SleuthThemeData.light()), - ('highContrastDark', const SleuthThemeData.highContrastDark()), - ('highContrastLight', const SleuthThemeData.highContrastLight()), + for (final (row, page) in [ + ('Encyclopedia', IssueEncyclopediaPage), + ('Guide', GuidePage), ]) { - testWidgets('$name: the collapsed pause indicator keeps 3:1 on the ' - 'banner', (tester) async { - controller.dispose(); - controller = SleuthController( - config: const SleuthConfig( - treeScanInterval: Duration(seconds: 1), - enabledDetectors: {DetectorType.frameTiming}, - ), + testWidgets('$row closes the menu and opens its page; closing the ' + 'page returns focus to More', (tester) async { + await tester.pumpWidget(pumpMenuCard(controller)); + await openFromMoreMenu(tester, row); + expect(find.byType(SleuthMoreMenu), findsNothing); + expect(find.byType(page), findsOneWidget); + + final state = tester.state>( + find.byType(FloatingIssuesCard), ); - controller.initializeDetectorsForTest(); - final fake = _FakeCoordinator(); - controller.debugCoordinatorForTest = fake; - - await tester.pumpWidget(pumpCardForBanner(controller, theme: theme)); - await tester.pumpAndSettle(); - await primeAndMergeCounts(tester, controller, fake, {'TinyCard': 4}); - - final header = find.text('Rebuilds: 4 across 1 widget'); - await tester.ensureVisible(header); - await tester.tap(header); + expect((state as OverlayLayerHost).closeInnermostLayer(), isTrue); await tester.pump(); - await tester.ensureVisible(find.byIcon(Icons.pause)); - await tester.tap(find.byIcon(Icons.pause)); await tester.pump(); - await tester.tap(header); - await tester.pump(); - - final pause = find.byIcon(Icons.pause); - final icon = tester.widget(pause).color!; - // The banner's tint, the nearest filled box behind the icon. - final tint = tester - .widgetList( - find.ancestor(of: pause, matching: find.byType(DecoratedBox)), - ) - .map((box) => box.decoration) - .whereType() - .firstWhere((d) => d.color != null) - .color!; - for (final host in const [Color(0xFF000000), Color(0xFFFFFFFF)]) { - final banner = composite(tint, composite(theme.cardBackground, host)); - expect( - wcagContrast(composite(icon, banner), banner), - greaterThanOrEqualTo(3), - reason: 'over $host', - ); - } + expect(find.byType(page), findsNothing); + final more = Focus.of(tester.element(find.byIcon(Icons.more_horiz))); + expect(more.hasPrimaryFocus, isTrue); }); } - testWidgets('panel is collapsed by default — top rows are hidden', ( + testWidgets('Copy snapshot closes the menu and copies', (tester) async { + String? copied; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + copied = (call.arguments as Map)['text'] as String?; + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + await tester.pumpWidget(pumpMenuCard(controller)); + await openFromMoreMenu(tester, 'Copy snapshot'); + expect(find.byType(SleuthMoreMenu), findsNothing); + expect(copied, contains('"currentIssues"')); + expect(find.text('Snapshot copied to clipboard'), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + }); + + testWidgets('a theme segment applies in place with no toast', ( tester, ) async { - // v0.15.2 UX knob #2: the panel starts collapsed. Header is the - // only row visible until the user expands it. This pins the - // "minimal noise when nothing surprising is happening" contract. - controller.dispose(); - controller = SleuthController( - config: const SleuthConfig( - treeScanInterval: Duration(seconds: 1), - enabledDetectors: {DetectorType.frameTiming}, + await tester.pumpWidget(pumpMenuCard(controller)); + await openMoreMenu(tester); + await tester.ensureVisible(find.bySemanticsLabel('Auto theme')); + await tester.pump(); + expect( + tester.getSemantics(find.bySemanticsLabel('Auto theme')), + matchesSemantics( + label: 'Auto theme', + isButton: true, + isInMutuallyExclusiveGroup: true, + hasCheckedState: true, + isChecked: true, + hasTapAction: true, ), ); - controller.initializeDetectorsForTest(); - final fake = _FakeCoordinator(); - controller.debugCoordinatorForTest = fake; - - await tester.pumpWidget(pumpCardForBanner(controller)); - await tester.pumpAndSettle(); - - await primeAndMergeCounts(tester, controller, fake, { - 'ProductCard': 12, - 'PriceTag': 5, - }); + await tester.tap(find.bySemanticsLabel('Dark theme')); + await tester.pump(); + expect(controller.overlayUiState.themeMode, SleuthThemeMode.dark); + // The menu stays open and no toast reports the change. + expect(find.byType(SleuthMoreMenu), findsOneWidget); + expect(find.textContaining('Theme:'), findsNothing); + expect( + tester.getSemantics(find.bySemanticsLabel('Dark theme')), + matchesSemantics( + label: 'Dark theme', + isButton: true, + isInMutuallyExclusiveGroup: true, + hasCheckedState: true, + isChecked: true, + hasTapAction: true, + ), + ); + }); - // Header summary is visible… - expect(find.text('Rebuilds: 17 across 2 widgets'), findsOneWidget); - // …but the per-widget rows + drilldown link are not. - expect(find.textContaining('ProductCard'), findsNothing); - expect(find.textContaining('See all'), findsNothing); - // Collapsed chevron is shown (expand_more, not expand_less). - expect(find.byIcon(Icons.expand_more), findsOneWidget); - expect(find.byIcon(Icons.expand_less), findsNothing); + testWidgets('the scrim takes the tap that closes the menu', (tester) async { + var appTaps = 0; + controller.issuesNotifier.value = [_pinIssue(id: 'under_scrim')]; + await tester.pumpWidget( + pumpMenuCard( + controller, + onAppTap: () { + appTaps++; + }, + ), + ); + await openMoreMenu(tester); + // On the card beside the panel: the tap closes the menu, nothing + // else. + final card = tester.getRect(find.byType(IssueCard)); + final panel = tester.getRect(find.byType(SleuthMoreMenu)); + expect(card.right, greaterThan(panel.right + 10)); + await tester.tapAt(Offset(panel.right + 5, card.center.dy)); + await tester.pump(); + expect(find.byType(SleuthMoreMenu), findsNothing); + // The card did not expand: its detail is not shown. + expect(find.text('Detail'), findsNothing); + expect(appTaps, 0); }); - testWidgets('tapping the header expands top-3 rows + footer link', ( + testWidgets('a tap on the app closes the menu and reaches the app', ( tester, ) async { - // v0.15.2 UX knob #1: top-N = 3. The expanded panel shows the - // three highest-count widget types, the inflation footnote, and - // the "See all N →" drilldown link. - controller.dispose(); - controller = SleuthController( - config: const SleuthConfig( - treeScanInterval: Duration(seconds: 1), - enabledDetectors: {DetectorType.frameTiming}, + var appTaps = 0; + await tester.pumpWidget( + pumpMenuCard( + controller, + onAppTap: () { + appTaps++; + }, ), ); - controller.initializeDetectorsForTest(); - final fake = _FakeCoordinator(); - controller.debugCoordinatorForTest = fake; - - await tester.pumpWidget(pumpCardForBanner(controller)); - await tester.pumpAndSettle(); - - await primeAndMergeCounts(tester, controller, fake, { - 'ProductCard': 20, - 'PriceTag': 10, - 'Avatar': 5, - 'BadgeChip': 2, // 4th — must NOT be shown inline. - }); - - // Tap the collapsed header to expand. - await tester.ensureVisible(find.text('Rebuilds: 37 across 4 widgets')); + await openMoreMenu(tester); + await tester.tapAt(const Offset(5, 590)); await tester.pump(); - await tester.tap(find.text('Rebuilds: 37 across 4 widgets')); - await tester.pump(); - - // Top-3 widget rows are now visible… - expect(find.text('ProductCard'), findsOneWidget); - expect(find.text('PriceTag'), findsOneWidget); - expect(find.text('Avatar'), findsOneWidget); - // …but the 4th widget is NOT inlined. - expect(find.text('BadgeChip'), findsNothing); - // Inflation footnote is rendered… - expect(find.text('includes inflations'), findsOneWidget); - // …and the drilldown link uses the full hotspot count (4). - expect(find.text('See all 4'), findsOneWidget); - // Chevron flipped. - expect(find.byIcon(Icons.expand_less), findsOneWidget); - expect(find.byIcon(Icons.expand_more), findsNothing); + expect(find.byType(SleuthMoreMenu), findsNothing); + expect(appTaps, 1); }); - testWidgets( - 'tapping "See all" link pushes the RebuildStatsPage drilldown', - (tester) async { - // The drilldown is reached via expand → tap link, NOT via tap on - // the header (which now toggles expansion). This test pins that - // wiring end-to-end through the same snapshot-and-push code path - // the v0.15.0/v0.15.1 rollup IssueCard used before removal. - // - // Needs ≥ 4 widget types because since v0.15.2 the card hides the - // redundant "See all N →" link when widgetCount ≤ topN = 3 (the - // inline rows already show every widget in that case, so a drilldown - // would surface nothing new). - controller.dispose(); - controller = SleuthController( - config: const SleuthConfig( - treeScanInterval: Duration(seconds: 1), - enabledDetectors: {DetectorType.frameTiming}, - ), - ); - controller.initializeDetectorsForTest(); - final fake = _FakeCoordinator(); - controller.debugCoordinatorForTest = fake; - - await tester.pumpWidget(pumpCardForBanner(controller)); - await tester.pumpAndSettle(); - - await primeAndMergeCounts(tester, controller, fake, { - 'ProductCard': 7, - 'PriceTag': 3, - 'Avatar': 2, - 'Footer': 1, - }); - - // Drilldown not yet visible. - expect(find.byType(RebuildStatsPage), findsNothing); + testWidgets('minimize and a header drag close the menu', (tester) async { + await tester.pumpWidget(pumpMenuCard(controller)); + await openMoreMenu(tester); + await tester.drag(find.byIcon(Icons.pets), const Offset(-30, 40)); + await tester.pump(); + expect(find.byType(SleuthMoreMenu), findsNothing); - // Expand the panel. - await tester.ensureVisible(find.text('Rebuilds: 13 across 4 widgets')); - await tester.pump(); - await tester.tap(find.text('Rebuilds: 13 across 4 widgets')); - await tester.pump(); + await openMoreMenu(tester); + await tester.tap(find.byIcon(Icons.minimize)); + await tester.pump(); + expect(find.byType(SleuthMoreMenu), findsNothing); + }); - // Now tap the "See all 4 →" link. - // The banners scroll when the card is short. - await tester.ensureVisible(find.text('See all 4')); - await tester.pump(); - await tester.tap(find.text('See all 4')); - await tester.pump(); + testWidgets('keyboard focus moves into the menu and back to More', ( + tester, + ) async { + await tester.pumpWidget(pumpMenuCard(controller)); + await openMoreMenu(tester); + final panelScope = FocusScope.of(tester.element(moreMenuRow('Guide'))); + expect(panelScope.hasFocus, isTrue); - // Drilldown is now mounted. - expect(find.byType(RebuildStatsPage), findsOneWidget); - }, - ); + // Escape closes the menu before the card. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(find.byType(SleuthMoreMenu), findsNothing); + expect(find.byType(FloatingIssuesCard), findsOneWidget); + expect( + Focus.of(tester.element(find.byIcon(Icons.more_horiz))).hasPrimaryFocus, + isTrue, + ); + }); - testWidgets('expanded panel shows pause icon — tap freezes counts', ( + testWidgets('opening focuses the first enabled row, and Enter opens it', ( tester, ) async { - // The pause/resume toggle freezes the rendered counts so the user - // can read a stable snapshot while live attribution continues to - // accumulate. Frozen counts ignore subsequent merges until Resume - // (or auto-resume on route change) clears the freeze. - // - // We need ≥ 4 widget types in the panel so that the inline panel - // renders the "See all N →" drilldown link (since v0.15.2 the link is - // suppressed when widgetCount ≤ topN = 3 because the inline rows - // already show everything). Without ≥ 4 the panel↔drilldown - // contract assertion below could not exercise the snapshot path. - controller.dispose(); - controller = SleuthController( - config: const SleuthConfig( - treeScanInterval: Duration(seconds: 1), - enabledDetectors: {DetectorType.frameTiming}, - ), + await tester.pumpWidget(pumpMenuCard(controller)); + await openMoreMenu(tester); + // Startup and Rebuild stats have nothing to show and are disabled. + expect( + Focus.of(tester.element(moreMenuRow('Encyclopedia'))).hasPrimaryFocus, + isTrue, ); - controller.initializeDetectorsForTest(); - final fake = _FakeCoordinator(); - controller.debugCoordinatorForTest = fake; - await tester.pumpWidget(pumpCardForBanner(controller)); - await tester.pumpAndSettle(); - - await primeAndMergeCounts(tester, controller, fake, { - 'ProductCard': 8, - 'PriceTag': 4, - 'Avatar': 2, - 'Footer': 1, - }); - - await tester.ensureVisible(find.text('Rebuilds: 15 across 4 widgets')); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); await tester.pump(); - await tester.tap(find.text('Rebuilds: 15 across 4 widgets')); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byType(IssueEncyclopediaPage), findsOneWidget); + expect(find.byType(SleuthMoreMenu), findsNothing); + }); + + testWidgets('the focused row is outlined only while the keyboard drives ' + 'focus', (tester) async { + Iterable shown() => tester + .widgetList(find.byType(SleuthFocusOutline)) + .where((o) => o.show); + await tester.pumpWidget(pumpMenuCard(controller)); + await openMoreMenu(tester); + // Opened by touch: focus moved, no outline. + expect(shown(), isEmpty); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); await tester.pump(); + expect(shown(), hasLength(1)); + final focused = FocusManager.instance.primaryFocus!.context!; + expect( + focused.findAncestorWidgetOfExactType(), + isNotNull, + ); + expect(shown().single.color, const SleuthThemeData().checkboxActive); + }); - // Pause icon is visible only while expanded. - expect(find.byIcon(Icons.pause), findsOneWidget); - expect(find.byIcon(Icons.play_arrow), findsNothing); - - await tester.ensureVisible(find.byIcon(Icons.pause)); - await tester.pump(); - await tester.tap(find.byIcon(Icons.pause)); + testWidgets('the selected theme segment is outlined and filled; the ' + 'others show outlined icons', (tester) async { + await tester.pumpWidget(pumpMenuCard(controller)); + await openMoreMenu(tester); + await tester.ensureVisible(moreMenuRow('Theme')); await tester.pump(); + expect(find.byIcon(Icons.light_mode_outlined), findsOneWidget); + expect(find.byIcon(Icons.dark_mode_outlined), findsOneWidget); - // Pause flipped to play_arrow (Resume). - expect(find.byIcon(Icons.pause), findsNothing); - expect(find.byIcon(Icons.play_arrow), findsOneWidget); - - // Header still shows the frozen total even after a fresh merge. - // Drive a second merge that would change the live total — the - // frozen view must NOT update. - fake.nextSnapshot = const DebugSnapshot( - rebuildCounts: {'ProductCard': 5}, - totalPaintCount: 0, - elapsed: Duration(milliseconds: 500), - source: RebuildCountSource.flutterTimeline, + await tester.tap(find.bySemanticsLabel('Dark theme')); + await tester.pump(); + expect(find.byIcon(Icons.dark_mode), findsOneWidget); + expect(find.byIcon(Icons.light_mode_outlined), findsOneWidget); + final segment = tester.widget( + find + .ancestor( + of: find.byIcon(Icons.dark_mode), + matching: find.byType(DecoratedBox), + ) + .first, ); - controller.scanTreeFullPathForTest( - tester.element(find.byType(MaterialApp)), + final border = (segment.decoration as BoxDecoration).border! as Border; + expect(border.top.width, 1.5); + expect( + border.top.color, + SleuthTheme.of( + tester.element(find.byIcon(Icons.dark_mode)), + ).checkboxActive, ); - await tester.pump(); - - // Frozen at 15, not 20 (15 + 5 from the new merge). - expect(find.text('Rebuilds: 15 across 4 widgets'), findsOneWidget); - expect(find.text('Rebuilds: 20 across 4 widgets'), findsNothing); - - // Panel ↔ drilldown contract. While paused, tapping the - // "See all N →" link must open RebuildStatsPage against the - // FROZEN counts, not the live `session.rebuildCountsByType`. - // Pre-fix the drilldown snapshot was read from `session` at tap - // time, so a paused panel showing 15 would push a drilldown - // showing 20. This assertion pins - // the fix end-to-end through the full - // banner.onTap → _onSeeAllRebuildsTap(overrideCounts) → push - // path. - await tester.ensureVisible(find.text('See all 4')); - await tester.pump(); - await tester.tap(find.text('See all 4')); - await tester.pumpAndSettle(); - expect(find.byType(RebuildStatsPage), findsOneWidget); - - // Both the panel (still mounted under the drilldown) and the - // drilldown render `×8`-style text, so we must scope all finds - // to the drilldown subtree to assert the contract precisely. - Finder inDrilldown(Finder f) => - find.descendant(of: find.byType(RebuildStatsPage), matching: f); - - // Drilldown header summary chips: 15 total, 4 distinct types - // (matches the FROZEN snapshot, not the live total of 20). - expect(inDrilldown(find.text('15')), findsOneWidget); - expect(inDrilldown(find.text('20')), findsNothing); - // The live ProductCard count would be 13 (8 + 5); the frozen - // count is 8, so the drilldown row for ProductCard must read ×8. - expect(inDrilldown(find.text('\u00d78')), findsOneWidget); - expect(inDrilldown(find.text('\u00d713')), findsNothing); }); - testWidgets('banner is hidden while the card is minimized', (tester) async { - controller.dispose(); - controller = SleuthController( - config: const SleuthConfig( - treeScanInterval: Duration(seconds: 1), - enabledDetectors: {DetectorType.frameTiming}, + testWidgets('on iOS the selected theme segment reads as selected', ( + tester, + ) async { + await tester.pumpWidget(pumpMenuCard(controller)); + await openMoreMenu(tester); + // `flagsCollection` is not available on the 3.32 floor. + bool selected(String label) => tester + .getSemantics(find.bySemanticsLabel(label)) + // ignore: deprecated_member_use + .hasFlag(SemanticsFlag.isSelected); + expect(selected('Auto theme'), isTrue); + expect(selected('Dark theme'), isFalse); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + + testWidgets('the panel fits the card and the More button reads its ' + 'state', (tester) async { + await tester.pumpWidget(pumpMenuCard(controller)); + expect( + tester.getSemantics(find.bySemanticsLabel('More')), + matchesSemantics( + label: 'More', + isButton: true, + hasExpandedState: true, + hasTapAction: true, ), ); - controller.initializeDetectorsForTest(); - final fake = _FakeCoordinator(); - controller.debugCoordinatorForTest = fake; - - await tester.pumpWidget(pumpCardForBanner(controller)); - await tester.pumpAndSettle(); - - await primeAndMergeCounts(tester, controller, fake, {'Card': 6}); - expect(find.byIcon(Icons.repeat), findsOneWidget); - - // Minimize the card — the entire body (including all banners) is - // suppressed. The chip should disappear with the rest of the body. - await tester.tap(find.byIcon(Icons.minimize)); - await tester.pump(); - - expect(find.byIcon(Icons.repeat), findsNothing); - expect(find.textContaining('Rebuilds:'), findsNothing); + await openMoreMenu(tester); + expect( + tester.getSemantics(find.bySemanticsLabel('More').first), + matchesSemantics( + label: 'More', + isButton: true, + hasExpandedState: true, + isExpanded: true, + hasTapAction: true, + ), + ); + final card = tester.getRect(find.byType(Material).first); + final panel = tester.getRect(find.byType(SleuthMoreMenu)); + expect(panel.width, lessThanOrEqualTo(236)); + expect(panel.left, greaterThanOrEqualTo(card.left)); + expect(panel.right, lessThanOrEqualTo(card.right)); + expect(panel.bottom, lessThanOrEqualTo(card.bottom)); }); }); @@ -1454,48 +1331,6 @@ void main() { ), ); - testWidgets('rebuild banner updates on ticks that leave issues unchanged', ( - tester, - ) async { - controller.dispose(); - controller = SleuthController( - config: const SleuthConfig( - enabledDetectors: {DetectorType.frameTiming}, - ), - ); - controller.initializeDetectorsForTest(); - final fake = _FakeCoordinator(); - controller.debugCoordinatorForTest = fake; - var notifications = 0; - controller.issuesNotifier.addListener(() => notifications++); - - await tester.pumpWidget(host(controller)); - final root = tester.element(find.byType(MaterialApp)); - controller.scanTreeFullPathForTest(root); - fake.nextSnapshot = const DebugSnapshot( - rebuildCounts: {'ProductCard': 3}, - totalPaintCount: 0, - elapsed: Duration(milliseconds: 500), - source: RebuildCountSource.flutterTimeline, - ); - controller.scanTreeFullPathForTest(root); - await tester.pump(); - expect(find.text('Rebuilds: 3 across 1 widget'), findsOneWidget); - final notificationsBefore = notifications; - - fake.nextSnapshot = const DebugSnapshot( - rebuildCounts: {'ProductCard': 4}, - totalPaintCount: 0, - elapsed: Duration(milliseconds: 500), - source: RebuildCountSource.flutterTimeline, - ); - controller.scanTreeFullPathForTest(root); - await tester.pump(); - - expect(notifications, notificationsBefore); - expect(find.text('Rebuilds: 7 across 1 widget'), findsOneWidget); - }); - testWidgets( 'recurrence badge updates on ticks that leave issues unchanged', (tester) async { @@ -1525,11 +1360,29 @@ void main() { ); }); - group('Header theme toggle', () { + group('Theme in the More menu', () { SleuthThemeData theme(WidgetTester tester) => tester.widget(find.byType(SleuthTheme)).data; - testWidgets('cycles System, Light, Dark with a toast and persists', ( + /// Taps the [name] segment, opening the menu first when it is closed. + Future pickTheme(WidgetTester tester, String name) async { + if (find.byType(SleuthMoreMenu).evaluate().isEmpty) { + await openMoreMenu(tester); + } + final segment = find.bySemanticsLabel('$name theme'); + await tester.ensureVisible(segment); + await tester.pump(); + await tester.tap(segment); + await tester.pump(); + } + + bool isOn(WidgetTester tester, String name) => tester + .getSemantics(find.bySemanticsLabel('$name theme')) + // `flagsCollection` is not available on the 3.32 floor. + // ignore: deprecated_member_use + .hasFlag(SemanticsFlag.isChecked); + + testWidgets('sets Light, Dark and Auto in place and persists', ( tester, ) async { final store = InMemorySleuthStateStore(); @@ -1543,11 +1396,10 @@ void main() { controller.updateTheme(const SleuthThemeData.highContrastLight()); await tester.pump(); - final toggle = find.bySemanticsLabel('Toggle theme'); - expect(tester.getSemantics(toggle).value, 'System'); + await openMoreMenu(tester); + expect(isOn(tester, 'Auto'), isTrue); - await tester.tap(toggle); - await tester.pump(); + await pickTheme(tester, 'Light'); expect(controller.overlayUiState.themeMode, SleuthThemeMode.light); // The override stays; Light takes precedence over it. expect( @@ -1555,37 +1407,28 @@ void main() { same(const SleuthThemeData.highContrastLight()), ); expect(identical(theme(tester), const SleuthThemeData.light()), isTrue); - expect(find.text('Theme: Light'), findsOneWidget); - expect(tester.getSemantics(toggle).value, 'Light'); + expect(isOn(tester, 'Light'), isTrue); + expect(find.textContaining('Theme:'), findsNothing); - await tester.tap(toggle); - await tester.pump(); + await pickTheme(tester, 'Dark'); expect(controller.overlayUiState.themeMode, SleuthThemeMode.dark); expect(identical(theme(tester), const SleuthThemeData()), isTrue); - expect(find.text('Theme: Dark'), findsOneWidget); - await tester.tap(toggle); - await tester.pump(); + await pickTheme(tester, 'Auto'); expect(controller.overlayUiState.themeMode, SleuthThemeMode.system); - expect(find.text('Theme: System'), findsOneWidget); - // System shows the app's override again. + // Auto shows the app's override again. expect( identical(theme(tester), const SleuthThemeData.highContrastLight()), isTrue, ); - await tester.tap(toggle); + await pickTheme(tester, 'Light'); await tester.pump(const Duration(seconds: 3)); final saved = jsonDecode(store.json!) as Map; expect(saved['themeMode'], 'light'); }); - }); - - group('Header theme toggle precedence', () { - SleuthThemeData theme(WidgetTester tester) => - tester.widget(find.byType(SleuthTheme)).data; - testWidgets('Light then System brings the override back', (tester) async { + testWidgets('Light then Auto brings the override back', (tester) async { const override = SleuthThemeData.highContrastDark(); final controller = await pumpOverlay( tester, @@ -1596,22 +1439,16 @@ void main() { await tester.pump(); expect(identical(theme(tester), override), isTrue); - final toggle = find.bySemanticsLabel('Toggle theme'); - await tester.tap(toggle); - await tester.pump(); + await pickTheme(tester, 'Light'); expect(controller.overlayUiState.themeMode, SleuthThemeMode.light); expect(identical(theme(tester), const SleuthThemeData.light()), isTrue); - // Light -> Dark -> System. - await tester.tap(toggle); - await tester.pump(); - await tester.tap(toggle); - await tester.pump(); + await pickTheme(tester, 'Auto'); expect(controller.overlayUiState.themeMode, SleuthThemeMode.system); expect(identical(theme(tester), override), isTrue); }); - testWidgets('updateTheme while Dark switches the toggle to System', ( + testWidgets('updateTheme while Dark switches the menu to Auto', ( tester, ) async { const override = SleuthThemeData.highContrastLight(); @@ -1627,12 +1464,10 @@ void main() { await tester.pump(); expect(controller.overlayUiState.themeMode, SleuthThemeMode.system); expect(identical(theme(tester), override), isTrue); - expect( - tester.getSemantics(find.bySemanticsLabel('Toggle theme')).value, - 'System', - ); - // The icon follows the mode, not the theme's brightness. - expect(find.byIcon(Icons.brightness_auto), findsOneWidget); + await openMoreMenu(tester); + await tester.ensureVisible(find.bySemanticsLabel('Auto theme')); + await tester.pump(); + expect(isOn(tester, 'Auto'), isTrue); // Clearing the override leaves the mode alone. controller.updateTheme(null); @@ -1641,17 +1476,21 @@ void main() { expect(identical(theme(tester), const SleuthThemeData()), isTrue); }); - testWidgets('the toggle icon follows the mode', (tester) async { + testWidgets('the segment that is on follows the mode', (tester) async { final controller = await pumpOverlay( tester, platformBrightness: Brightness.dark, themeMode: SleuthThemeMode.light, ); await openDashboard(tester, controller); - expect(find.byIcon(Icons.light_mode), findsOneWidget); + await openMoreMenu(tester); + await tester.ensureVisible(find.bySemanticsLabel('Light theme')); + await tester.pump(); + expect(isOn(tester, 'Light'), isTrue); controller.overlayUiState.themeMode = SleuthThemeMode.dark; await tester.pump(); - expect(find.byIcon(Icons.dark_mode), findsOneWidget); + expect(isOn(tester, 'Dark'), isTrue); + expect(isOn(tester, 'Light'), isFalse); }); }); @@ -1659,7 +1498,7 @@ void main() { testWidgets('closes the open page, then the card', (tester) async { final controller = await pumpOverlay(tester); await openDashboard(tester, controller); - await tester.tap(find.bySemanticsLabel('Guide')); + await openFromMoreMenu(tester, 'Guide'); await tester.pumpAndSettle(); expect(find.text('Sleuth Guide'), findsOneWidget); @@ -1743,7 +1582,7 @@ void main() { ); await tester.pumpAndSettle(); await openDashboard(tester, controller); - await tester.tap(find.bySemanticsLabel('Guide')); + await openFromMoreMenu(tester, 'Guide'); await tester.pumpAndSettle(); expect(find.text('Sleuth Guide'), findsOneWidget); diff --git a/test/ui/guide_page_test.dart b/test/ui/guide_page_test.dart index adb49493..f2a4ca1a 100644 --- a/test/ui/guide_page_test.dart +++ b/test/ui/guide_page_test.dart @@ -84,7 +84,7 @@ void main() { ) async { final controller = await pumpOverlay(tester); await openDashboard(tester, controller); - await tester.tap(find.bySemanticsLabel('Guide')); + await openFromMoreMenu(tester, 'Guide'); await tester.pumpAndSettle(); expect(find.byType(GuidePage), findsOneWidget); diff --git a/test/ui/overlay_focus_test.dart b/test/ui/overlay_focus_test.dart index a91f776a..c8994d1d 100644 --- a/test/ui/overlay_focus_test.dart +++ b/test/ui/overlay_focus_test.dart @@ -7,6 +7,7 @@ import 'package:sleuth/src/ui/floating_issues_card.dart'; import 'package:sleuth/src/ui/hidden_issues_page.dart'; import 'package:sleuth/src/ui/issue_card.dart'; import 'package:sleuth/src/ui/issue_encyclopedia_page.dart'; +import 'package:sleuth/src/ui/more_menu.dart'; import '../helpers/overlay_harness.dart'; @@ -60,7 +61,7 @@ Future<(_HostApp, SleuthController)> _pumpHostApp( } Future _openGuide(WidgetTester tester) async { - await tester.tap(find.bySemanticsLabel('Guide')); + await openFromMoreMenu(tester, 'Guide'); await tester.pumpAndSettle(); expect(find.text('Sleuth Guide'), findsOneWidget); } @@ -172,7 +173,7 @@ void main() { app.field.requestFocus(); await tester.pump(); await openDashboard(tester, controller); - await tester.tap(find.text('1 hidden')); + await openFromMoreMenu(tester, 'Hidden issues'); await tester.pumpAndSettle(); expect(find.byType(HiddenIssuesPage), findsOneWidget); expect(app.field.hasFocus, isFalse); @@ -227,9 +228,102 @@ void main() { expect(find.byType(FloatingIssuesCard), findsNothing); expect(app.field.hasPrimaryFocus, isTrue); }); + + testWidgets('the More menu takes focus from the app, and closing the ' + 'dashboard gives it back', (tester) async { + final (app, controller) = await _pumpHostApp(tester, config: config); + app.field.requestFocus(); + await tester.pump(); + await openDashboard(tester, controller); + await openMoreMenu(tester); + expect(app.field.hasFocus, isFalse); + expect(_focusInOverlay(), isTrue); + + // Closing the menu leaves focus on More. + await systemBack(tester); + expect(_focusInOverlay(), isTrue); + + controller.overlayUiState.dashboardOpen = false; + await tester.pumpAndSettle(); + expect(app.field.hasPrimaryFocus, isTrue); + }); + + testWidgets('with the menu open, Escape closes the menu even when an ' + 'app text field had focus, then the card', (tester) async { + final (app, controller) = await _pumpHostApp(tester, config: config); + app.field.requestFocus(); + await tester.pump(); + await openDashboard(tester, controller); + await openMoreMenu(tester); + expect(find.byType(SleuthMoreMenu), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(find.byType(SleuthMoreMenu), findsNothing); + expect(controller.overlayUiState.dashboardOpen, isTrue); + + // Focus is on More now, not in the app's field, so Escape closes the + // card. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(controller.overlayUiState.dashboardOpen, isFalse); + expect(app.field.hasPrimaryFocus, isTrue); + }); }); group('Screen reader focus and list position', () { + testWidgets('opening the More menu moves screen reader focus to the ' + 'panel', (tester) async { + final focusEvents = _recordScreenReaderFocus(tester); + final controller = await pumpOverlay(tester, config: config); + await openDashboard(tester, controller); + focusEvents.clear(); + await openMoreMenu(tester); + final panel = tester.getSemantics( + find.descendant( + of: find.byType(SleuthMoreMenu), + matching: find.bySemanticsLabel('More'), + ), + ); + expect(focusEvents, [panel.id]); + }); + + testWidgets('Show all moves screen reader focus to the first chip', ( + tester, + ) async { + final focusEvents = _recordScreenReaderFocus(tester); + final controller = await pumpOverlay(tester, config: config); + controller.issuesNotifier.value = [ + _rebuildIssue(0), + PerformanceIssue( + severity: IssueSeverity.critical, + category: IssueCategory.build, + confidence: IssueConfidence.confirmed, + title: 'Critical one', + detail: 'Detail', + fixHint: 'Fix', + stableId: 'critical_one', + ), + ]; + await openDashboard(tester, controller); + final critical = find.bySemanticsLabel('1 critical, on'); + await tester.ensureVisible(critical); + await tester.pump(); + await tester.tap(critical); + await tester.pump(); + expect(find.text('Showing 1 of 2'), findsOneWidget); + + focusEvents.clear(); + await tester.tap(find.text('Show all')); + await tester.pump(); + await tester.pump(); + expect(find.textContaining('Showing'), findsNothing); + final first = tester.getSemantics( + find.bySemanticsLabel('1 critical, on'), + ); + expect(focusEvents, [first.id]); + }); + testWidgets('opening the dashboard moves screen reader focus to the ' 'card header', (tester) async { final focusEvents = _recordScreenReaderFocus(tester); @@ -291,8 +385,8 @@ void main() { expect(focusEvents, [card.id]); }); - testWidgets('closing a page opened from the footer moves screen reader ' - 'focus to the header', (tester) async { + testWidgets('closing a page opened from the More menu moves screen ' + 'reader focus to More', (tester) async { final focusEvents = _recordScreenReaderFocus(tester); final controller = await pumpOverlay(tester, config: config); await openDashboard(tester, controller); @@ -300,8 +394,8 @@ void main() { focusEvents.clear(); await systemBack(tester); - final header = tester.getSemantics(find.bySemanticsLabel('Sleuth')); - expect(focusEvents, [header.id]); + final more = tester.getSemantics(find.bySemanticsLabel('More')); + expect(focusEvents, [more.id]); }); }); } diff --git a/test/ui/overlay_hide_filter_test.dart b/test/ui/overlay_hide_filter_test.dart index 832a2d3e..68c50b1d 100644 --- a/test/ui/overlay_hide_filter_test.dart +++ b/test/ui/overlay_hide_filter_test.dart @@ -12,6 +12,8 @@ import 'package:sleuth/src/ui/overlay_ui_state.dart'; import 'package:sleuth/src/ui/sleuth_theme.dart'; import '../helpers/contrast_helpers.dart'; +import '../helpers/overlay_harness.dart' + show moreMenuRow, openFromMoreMenu, openMoreMenu; PerformanceIssue _issue( String id, { @@ -94,7 +96,7 @@ void main() { await tester.pump(); } - /// The summary-bar chip for [severity] ("N severity, on" / "..., off, ..."). + /// The status-row chip for [severity] ("N severity, on" / "..., off, ..."). Finder chip(String severity) => find.byWidgetPredicate( (w) => w is Semantics && @@ -102,6 +104,15 @@ void main() { (w.properties.label?.contains(' $severity, ') ?? false), ); + /// Scrolls the [severity] chip into view and taps it. The test font is + /// wider than platform fonts, so three chips do not fit a 320 px card. + Future tapChip(WidgetTester tester, String severity) async { + await tester.ensureVisible(chip(severity)); + await tester.pump(); + await tester.tap(chip(severity)); + await tester.pump(); + } + /// Border of the [severity] chip's pill. BorderSide pillBorder(WidgetTester tester, String severity) { final pill = tester.widget( @@ -154,7 +165,7 @@ void main() { expect(find.text('Title child'), findsNothing); expect(find.text('Title other'), findsOneWidget); expect(find.text('Issue hidden'), findsOneWidget); - expect(find.text('1 hidden'), findsOneWidget); + expect(controller.overlayUiState.hiddenKeys, hasLength(1)); expect(tester.takeException(), isNull); await tester.tap(find.text('Undo')); @@ -748,31 +759,53 @@ void main() { }); group('Showing X of Y', () { - testWidgets('counts cards, not ids: one of two widgets hidden', ( + testWidgets('counts cards, not ids, under a severity filter', ( tester, ) async { controller.issuesNotifier.value = [ _issue('dup', widgetName: 'A'), _issue('dup', widgetName: 'B'), + _issue('crit', severity: IssueSeverity.critical), ]; - controller.overlayUiState - ..hide('dup|A') - ..hide('stale'); await pumpCard(tester); - expect(find.text('Showing 1 of 2'), findsOneWidget); + await tapChip(tester, 'critical'); + expect(find.text('Showing 2 of 3'), findsOneWidget); + // A screen reader hears the new count. + expect( + find.ancestor( + of: find.text('Showing 2 of 3'), + matching: find.byWidgetPredicate( + (w) => w is Semantics && w.properties.liveRegion == true, + ), + ), + findsOneWidget, + ); + + await tester.tap(find.text('Show all')); + await tester.pump(); + expect(find.textContaining('Showing'), findsNothing); + expect(find.text('Title crit'), findsOneWidget); + await drainToasts(tester); }); - testWidgets('a stale hidden key does not narrow', (tester) async { - controller.issuesNotifier.value = [_issue('a'), _issue('b')]; - controller.overlayUiState.hide('stale'); + testWidgets('hidden cards do not narrow; the More menu counts them', ( + tester, + ) async { + controller.issuesNotifier.value = [ + _issue('dup', widgetName: 'A'), + _issue('dup', widgetName: 'B'), + ]; + controller.overlayUiState + ..hide('dup|A') + ..hide('stale'); await pumpCard(tester); expect(find.textContaining('Showing'), findsNothing); - expect(find.text('2 confirmed'), findsOneWidget); + expect(find.text('1 warning'), findsOneWidget); }); }); - group('Summary bar', () { - testWidgets('takes 36 px from the list and two collapsed rows fit at ' + group('Status row', () { + testWidgets('sits above the list at 48 px and two collapsed rows fit at ' 'the minimum card height', (tester) async { tester.view.physicalSize = const Size(800, 400); tester.view.devicePixelRatio = 1; @@ -807,11 +840,12 @@ void main() { ); expect(card.height, 300); // 400 * 0.55 is below the minimum - final chipTop = tester.getTopLeft(chip('warning')).dy; + final chipRect = tester.getRect(chip('warning')); final list = tester.getRect(find.byType(ListView)); - expect(list.top - chipTop, 36); - // The chip's hit box is still 48 tall. - expect(tester.getSize(chip('warning')).height, 48); + expect(chipRect.height, 48); + // The list starts below the row and its 1 px divider; nothing of + // the row lies over it. + expect(list.top, greaterThanOrEqualTo(chipRect.bottom + 1)); final rows = [ for (final e in find.byType(IssueCard).evaluate()) @@ -835,31 +869,26 @@ void main() { ); }); - testWidgets('a tap just below the bar beside the chips reaches the list', ( - tester, - ) async { + testWidgets('the frame rate sits at the end of the row', (tester) async { controller.issuesNotifier.value = [_issue('a')]; await pumpCard(tester); - final list = tester.getRect(find.byType(ListView)); - final title = tester.getRect(find.text('Title a')); - // Inside the chips' 48 px band, right of the chips. - await tester.tapAt(Offset(title.right - 2, list.top + 6)); - await tester.pump(); - expect(find.byIcon(Icons.push_pin), findsOneWidget); + final fps = tester.getRect(find.textContaining('fps')); + final row = tester.getRect(chip('warning')); + expect(fps.center.dy, closeTo(row.center.dy, 1)); + expect(fps.left, greaterThan(row.right)); }); }); - group('Footer and Hidden list', () { - test('footer label omits zero parts', () { - expect(hiddenFooterLabel(0, 0), isNull); - expect(hiddenFooterLabel(2, 0), '2 hidden'); - expect(hiddenFooterLabel(0, 3), '3 suppressed'); - expect(hiddenFooterLabel(2, 3), '2 hidden · 3 suppressed'); + group('Hidden issues row and Hidden list', () { + testWidgets('the More menu has no Hidden issues row at 0', (tester) async { + controller.issuesNotifier.value = [_issue('a')]; + await pumpCard(tester); + await openMoreMenu(tester); + expect(moreMenuRow('Hidden issues'), findsNothing); }); - testWidgets('footer opens the Hidden list; restore and restore all', ( - tester, - ) async { + testWidgets('the row counts hidden and suppressed issues and opens the ' + 'Hidden list; restore and restore all', (tester) async { controller.issuesNotifier.value = [_issue('a'), _issue('b')]; controller.suppressedCountNotifier.value = 3; controller.overlayUiState @@ -868,8 +897,11 @@ void main() { ..hide('gone'); await pumpCard(tester); - await tester.tap(find.text('3 hidden · 3 suppressed')); + await openMoreMenu(tester); + expect(find.bySemanticsLabel('Hidden issues, 6'), findsOneWidget); + await tester.tap(moreMenuRow('Hidden issues')); await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); expect(find.byType(HiddenIssuesPage), findsOneWidget); expect(find.text('Title a'), findsOneWidget); expect(find.text('Not detected right now'), findsOneWidget); @@ -891,8 +923,7 @@ void main() { ..hide('c') ..hide('a'); await pumpCard(tester); - await tester.tap(find.text('2 hidden')); - await tester.pump(); + await openFromMoreMenu(tester, 'Hidden issues'); await tester.tap(find.bySemanticsLabel('Restore all hidden issues')); await tester.pump(); @@ -910,8 +941,7 @@ void main() { controller.issuesNotifier.value = [_issue('b')]; controller.overlayUiState.hide('a'); await pumpCard(tester); - await tester.tap(find.text('1 hidden')); - await tester.pump(); + await openFromMoreMenu(tester, 'Hidden issues'); expect(find.text('Not detected right now'), findsOneWidget); controller.issuesNotifier.value = [_issue('a'), _issue('b')]; @@ -1009,8 +1039,7 @@ void main() { await pumpCard(tester); expect(find.text('Title child'), findsNothing); - await tester.tap(chip('critical')); - await tester.pump(); + await tapChip(tester, 'critical'); expect(find.text('Title root'), findsNothing); expect(find.text('Title child'), findsOneWidget); // One card shown by default (child collapsed under root), one now: @@ -1031,8 +1060,7 @@ void main() { await pumpCard(tester); expect(chip('warning'), findsNothing); - await tester.tap(chip('critical')); - await tester.pump(); + await tapChip(tester, 'critical'); expect(find.text('Title child'), findsOneWidget); expect(find.bySemanticsLabel('1 warning, on'), findsOneWidget); expect( @@ -1059,15 +1087,13 @@ void main() { ); await tester.pump(); - await tester.tap(chip('warning')); - await tester.pump(); + await tapChip(tester, 'warning'); expect(find.text('Title lay'), findsNothing); expect(controller.pendingIssueSelection, isNull); expect(controller.selectedHighlightNotifier.value, isNull); // Shown again, the card is no longer ticked. - await tester.tap(chip('warning')); - await tester.pump(); + await tapChip(tester, 'warning'); expect(tester.widget(find.byType(Checkbox)).value, isFalse); await drainToasts(tester); }); @@ -1081,8 +1107,7 @@ void main() { ..toggleSeverity(IssueSeverity.ok); await tester.pump(); - await tester.tap(chip('warning')); - await tester.pump(); + await tapChip(tester, 'warning'); expect(controller.overlayUiState.severityFilter, {IssueSeverity.warning}); expect(find.text('Keep at least one severity'), findsOneWidget); await drainToasts(tester); @@ -1098,8 +1123,7 @@ void main() { await expand(tester, 'a'); expect(find.byIcon(Icons.push_pin), findsOneWidget); - await tester.tap(chip('critical')); - await tester.pump(); + await tapChip(tester, 'critical'); expect(tester.takeException(), isNull); expect(find.byIcon(Icons.push_pin), findsNothing); final card = tester.widget(find.byType(IssueCard)); @@ -1124,21 +1148,17 @@ void main() { final border = pillBorder(tester, 'warning'); expect(border.color, theme.severityWarningText); expect(border.width, 1.5); - expect(chipGlyph('warning', Icons.check), findsOneWidget); + expect(chipGlyph('warning', Icons.warning), findsOneWidget); - // A tap in the part of the hit box that overlaps the list toggles. - final list = tester.getRect(find.byType(ListView)); - await tester.tapAt( - Offset(tester.getCenter(chip('warning')).dx, list.top + 8), - ); - await tester.pumpAndSettle(); + await tapChip(tester, 'warning'); expect( find.bySemanticsLabel('1 warning, off, tap to show'), findsOneWidget, ); - // Off: a dot instead of the check, and the plain border. - expect(chipGlyph('warning', Icons.check), findsNothing); - expect(pillBorder(tester, 'warning').width, 1); + // Off: the outlined icon and a quaternary outline. + expect(chipGlyph('warning', Icons.warning), findsNothing); + expect(chipGlyph('warning', Icons.warning_amber), findsOneWidget); + expect(pillBorder(tester, 'warning').color, theme.textQuaternary); await drainToasts(tester); }); @@ -1157,8 +1177,12 @@ void main() { _issue('o', severity: IssueSeverity.ok), ]; await pumpCard(tester, theme: theme); - for (final severity in ['critical', 'warning', 'ok']) { - expect(chipGlyph(severity, Icons.check), findsOneWidget); + for (final (severity, icon) in [ + ('critical', Icons.error), + ('warning', Icons.warning), + ('ok', Icons.check_circle), + ]) { + expect(chipGlyph(severity, icon), findsOneWidget); final border = pillBorder(tester, severity).color; for (final host in const [Color(0xFF000000), Color(0xFFFFFFFF)]) { final card = composite(theme.cardBackground, host); @@ -1197,8 +1221,7 @@ void main() { testWidgets('nothing matches the severity filter; Reset', (tester) async { controller.issuesNotifier.value = [_issue('w')]; await pumpCard(tester); - await tester.tap(chip('warning')); - await tester.pump(); + await tapChip(tester, 'warning'); expect(find.text('No issues match the severity filter'), findsOneWidget); await tester.tap(find.bySemanticsLabel('Reset')); @@ -1277,22 +1300,18 @@ void main() { controller.overlayUiState.hide('x'); await pumpCard(tester); await expand(tester, 'a'); - for (final label in [ - 'Copy issue details', - 'Hide this issue', - '1 hidden. Show hidden issues', - ]) { + for (final label in ['Copy issue details', 'Hide this issue']) { final size = tester.getSize(find.bySemanticsLabel(label)); expect(size.height, greaterThanOrEqualTo(48), reason: label); - } - for (final label in ['Copy issue details', 'Hide this issue']) { - expect( - tester.getSize(find.bySemanticsLabel(label)).width, - greaterThanOrEqualTo(48), - reason: label, - ); + expect(size.width, greaterThanOrEqualTo(48), reason: label); } expect(tester.getSize(chip('warning')).height, greaterThanOrEqualTo(48)); + + await openMoreMenu(tester); + expect( + tester.getSize(find.bySemanticsLabel('Hidden issues, 1')).height, + greaterThanOrEqualTo(48), + ); }); }); } diff --git a/test/ui/overlay_semantics_test.dart b/test/ui/overlay_semantics_test.dart index b1c78a5a..5874d831 100644 --- a/test/ui/overlay_semantics_test.dart +++ b/test/ui/overlay_semantics_test.dart @@ -48,7 +48,7 @@ void main() { await openDashboard(tester, controller); expect(_labels(tester), contains('app')); - await tester.tap(find.bySemanticsLabel('Guide')); + await openFromMoreMenu(tester, 'Guide'); await tester.pumpAndSettle(); expect(find.text('Sleuth Guide'), findsOneWidget); expect(_labels(tester), isNot(contains('app'))); @@ -57,6 +57,27 @@ void main() { expect(_labels(tester), contains('app')); handle.dispose(); }); + + testWidgets('the More menu blocks the app and the card body below it', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + final controller = await pumpOverlay(tester); + controller.issuesNotifier.value = mixedOverlayIssues(); + await openDashboard(tester, controller); + final title = controller.issuesNotifier.value.first.title; + expect(_labels(tester), containsAll(['app', title])); + + await openMoreMenu(tester); + final labels = _labels(tester); + expect(labels, isNot(contains('app'))); + expect(labels, isNot(contains(title))); + expect(labels, contains('Guide')); + + await systemBack(tester); + expect(_labels(tester), containsAll(['app', title])); + handle.dispose(); + }); }); group('Card header and grip', () { @@ -92,21 +113,36 @@ void main() { expect(node.hasAction(action), isFalse, reason: '$action'); } } - expect(_data(tester, header).value, '300 by 400 points, at 40, 100'); - expect(_data(tester, grip).value, '300 by 400 points'); + // The stored 300 px width is raised to the 320 px minimum. + expect(_data(tester, header).value, '320 by 400 points, at 40, 100'); + expect(_data(tester, grip).value, '320 by 400 points'); await performCustomAction(tester, grip, 'Taller'); - expect(_data(tester, grip).value, '300 by 448 points'); + expect(_data(tester, grip).value, '320 by 448 points'); await performCustomAction(tester, header, 'Move to top left'); - expect(_data(tester, header).value, '300 by 448 points, at 0, 0'); + expect(_data(tester, header).value, '320 by 448 points, at 0, 0'); handle.dispose(); }); - testWidgets('the theme toggle has a value and no hint', (tester) async { + testWidgets('More reads as a collapsed button, then expanded', ( + tester, + ) async { + final handle = tester.ensureSemantics(); await pumpCard(tester); - final toggle = _data(tester, find.bySemanticsLabel('Toggle theme')); - expect(toggle.value, 'System'); - expect(toggle.hint, isEmpty); + final more = find.bySemanticsLabel('More'); + expect(_hasFlag(tester, more.first, SemanticsFlag.isButton), isTrue); + expect(_hasFlag(tester, more.first, SemanticsFlag.isExpanded), isFalse); + expect(_data(tester, more.first).hint, isEmpty); + + await openMoreMenu(tester); + final button = find.byWidgetPredicate( + (w) => + w is Semantics && + w.properties.label == 'More' && + w.properties.button == true, + ); + expect(_hasFlag(tester, button, SemanticsFlag.isExpanded), isTrue); + handle.dispose(); }); testWidgets('the minimized count reads as issues', (tester) async { @@ -136,21 +172,23 @@ void main() { }); }); - group('Startup banner', () { + group('Startup row', () { tearDown(Sleuth.resetStartupForTest); - testWidgets('is a 48 px target', (tester) async { + testWidgets('is a 48 px target that reads its value', (tester) async { Sleuth.setStartupMetricsForTest( StartupMetrics(dartEntryTimestamp: DateTime(2026), ttffMs: 420), ); final controller = await pumpOverlay(tester); await openDashboard(tester, controller); - final banner = find.byWidgetPredicate( + await openMoreMenu(tester); + final row = find.byWidgetPredicate( (w) => w is Semantics && - w.properties.label == 'Startup metrics, tap for details', + w.properties.label == + 'Startup, time to first frame 420 milliseconds', ); - expect(tester.getSize(banner).height, greaterThanOrEqualTo(48)); + expect(tester.getSize(row).height, greaterThanOrEqualTo(48)); }); }); diff --git a/test/ui/overlay_toast_test.dart b/test/ui/overlay_toast_test.dart index 6db6a73a..3f64d76b 100644 --- a/test/ui/overlay_toast_test.dart +++ b/test/ui/overlay_toast_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sleuth/src/controller/sleuth_controller.dart'; import 'package:sleuth/src/ui/overlay_toast.dart'; @@ -215,6 +216,27 @@ void main() { }); group('Toast duration scale', () { + const copied = 'Snapshot copied to clipboard'; + + /// Copies the snapshot from the More menu, which shows a 2 s toast. + Future copySnapshot(WidgetTester tester) async { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async => null, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + await openMoreMenu(tester); + await tester.ensureVisible(moreMenuRow('Copy snapshot')); + await tester.pump(); + await tester.tap(moreMenuRow('Copy snapshot')); + await tester.pump(); + } + testWidgets('durationScale stretches every display time', (tester) async { await tester.pumpWidget(host()); toast.durationScale = 3; @@ -239,12 +261,11 @@ void main() { config: const SleuthConfig(treeScanInterval: Duration(hours: 1)), ); await openDashboard(tester, controller); - await tester.tap(find.byIcon(Icons.brightness_auto)); - await tester.pump(); - expect(find.text('Theme: Light'), findsOneWidget); + await copySnapshot(tester); + expect(find.text(copied), findsOneWidget); await tester.pump(const Duration(seconds: 2)); await tester.pump(const Duration(milliseconds: 300)); - expect(find.text('Theme: Light'), findsNothing); + expect(find.text(copied), findsNothing); }, semanticsEnabled: false); testWidgets('the card triples toasts and holds Undo while semantics is ' @@ -258,13 +279,12 @@ void main() { await openDashboard(tester, controller); expect(SemanticsBinding.instance.semanticsEnabled, isTrue); - await tester.tap(find.bySemanticsLabel('Toggle theme')); - await tester.pump(); + await copySnapshot(tester); await tester.pump(const Duration(seconds: 5)); - expect(find.text('Theme: Light'), findsOneWidget); + expect(find.text(copied), findsOneWidget); await tester.pump(const Duration(seconds: 2)); await tester.pump(const Duration(milliseconds: 300)); - expect(find.text('Theme: Light'), findsNothing); + expect(find.text(copied), findsNothing); await tester.tap(find.text(issue.title)); await tester.pump(); @@ -295,13 +315,12 @@ void main() { config: const SleuthConfig(treeScanInterval: Duration(hours: 1)), ); await openDashboard(tester, controller); - await tester.tap(find.bySemanticsLabel('Toggle theme')); - await tester.pump(); + await copySnapshot(tester); await tester.pump(const Duration(seconds: 5)); - expect(find.text('Theme: Light'), findsOneWidget); + expect(find.text(copied), findsOneWidget); await tester.pump(const Duration(seconds: 2)); await tester.pump(const Duration(milliseconds: 300)); - expect(find.text('Theme: Light'), findsNothing); + expect(find.text(copied), findsNothing); }); }); } diff --git a/test/ui/sleuth_theme_test.dart b/test/ui/sleuth_theme_test.dart index 0d97ad64..26b12f26 100644 --- a/test/ui/sleuth_theme_test.dart +++ b/test/ui/sleuth_theme_test.dart @@ -169,6 +169,24 @@ List<(String, Color, Color, double)> _pairs(SleuthThemeData t) { 3, ); } + for (final s in ['cardBackground/black', 'cardBackground/white']) { + for (final severity in IssueSeverity.values) { + add( + 'off ${severity.name} chip outline on $s', + t.textQuaternary, + surfaces[s]!, + 3, + ); + } + add('run label VM dot on $s', t.severityOkText, surfaces[s]!, 3); + add( + 'focus outline and selected theme segment on $s', + t.checkboxActive, + surfaces[s]!, + 3, + ); + add('run label no-VM dot on $s', t.severityWarningText, surfaces[s]!, 3); + } add('trigger icon on critical', t.triggerIconColor, t.severityCritical, 3); add( 'trigger icon on warning', @@ -224,6 +242,42 @@ void main() { }); } + // The divider only separates regions that differ anyway, so it is + // decorative and below 3:1 outside the high-contrast presets. The + // ratios are pinned so a token change is a decision. + const dividerRatios = { + 'dark': (onCard: 2.17, onCardDefault: 1.36), + 'light': (onCard: 1.95, onCardDefault: 1.58), + 'highContrastDark': (onCard: 6.46, onCardDefault: 4.06), + 'highContrastLight': (onCard: 4.83, onCardDefault: 3.90), + }; + for (final preset in _presets.entries) { + test('${preset.key}: cardDivider ratios are pinned', () { + final t = preset.value; + final pinned = dividerRatios[preset.key]!; + expect( + wcagContrast(t.cardDivider, composite(t.cardBackground, _black)), + closeTo(pinned.onCard, 0.01), + ); + expect( + wcagContrast(t.cardDivider, t.cardDefault), + closeTo(pinned.onCardDefault, 0.01), + ); + // In dark, border equals cardDefault; the divider must not. + expect(t.cardDivider, isNot(t.cardDefault)); + }); + } + + test('cardBackground is opaque in every preset and fromSeed', () { + for (final t in [ + ..._presets.values, + for (final b in Brightness.values) + SleuthThemeData.fromSeed(const Color(0xFF6750A4), brightness: b), + ]) { + expect(t.cardBackground.a, 1); + } + }); + for (final preset in _presets.entries) { test('${preset.key}: the trigger badge colour is opaque', () { expect(preset.value.triggerBadgeBg.a, 1); @@ -492,7 +546,7 @@ void main() { // Surfaces expect(t.pageBackground, const Color(0xFF1E1E2E)); expect(t.sectionBackground, const Color(0xFF252536)); - expect(t.cardBackground, const Color(0xF51E1E2E)); + expect(t.cardBackground, const Color(0xFF1E1E2E)); expect(t.border, const Color(0xFF374151)); // Text hierarchy expect(t.textPrimary, const Color(0xFFFFFFFF)); diff --git a/test/ui/text_scale_test.dart b/test/ui/text_scale_test.dart index afd01a2d..a0aacc66 100644 --- a/test/ui/text_scale_test.dart +++ b/test/ui/text_scale_test.dart @@ -16,7 +16,8 @@ const _adapter = AiChatAdapter(sendMessage: _reply); Stream _reply(AiChatRequest request) => Stream.value('ok'); -/// Overlay on a 320 x 568 view with a 220 px card and the mixed issue set. +/// Overlay on a 320 x 568 view with the narrowest card it allows (288 px: +/// the screen minus 16 px margins) and the mixed issue set. Future _pumpSmall( WidgetTester tester, double scale, { @@ -76,12 +77,6 @@ Future _tapFinder(WidgetTester tester, Finder finder) async { Future _tapLabel(WidgetTester tester, String label) => _tapFinder(tester, find.bySemanticsLabel(label)); -/// The [Semantics] widget labelled [label], for a node that merges its -/// children's text into its label. -Finder _semanticsWidget(String label) => find.byWidgetPredicate( - (w) => w is Semantics && w.properties.label == label, -); - /// Startup metrics with every section of the startup page filled. final _startupMetrics = StartupMetrics( dartEntryTimestamp: DateTime(2026), @@ -112,7 +107,7 @@ const _rebuildCounts = { void main() { for (final scale in [1.3, 2.0]) { - group('at ${scale}x on a 220 px card', () { + group('at ${scale}x on a 320 px screen', () { testWidgets('card, expanded issues and every page lay out', ( tester, ) async { @@ -141,9 +136,11 @@ void main() { await tester.tap(find.text(issues.first.title)); await _settle(tester, 'expand root'); - // FPS explainer. - await _tapLabel(tester, 'Show FPS explainer'); - await _settle(tester, 'FPS explainer'); + // The More menu, closed again with back. + await openMoreMenu(tester); + await _settle(tester, 'More menu'); + await systemBack(tester); + await _settle(tester, 'back from More menu'); // Learn more -> encyclopedia, Ask AI -> chat. await _tapFinder( @@ -165,9 +162,9 @@ void main() { await systemBack(tester); await _settle(tester, 'back from AI chat'); - // Footer pages. + // Pages in the More menu. for (final label in ['Encyclopedia', 'Guide']) { - await _tapLabel(tester, label); + await openFromMoreMenu(tester, label); await _settle(tester, label); await systemBack(tester); await _settle(tester, 'back from $label'); @@ -176,7 +173,7 @@ void main() { // Hide one, then the Hidden list. controller.overlayUiState.hide('slow_request'); await _settle(tester, 'hidden'); - await _tapLabel(tester, '1 hidden. Show hidden issues'); + await openFromMoreMenu(tester, 'Hidden issues'); await _settle(tester, 'hidden page'); await systemBack(tester); await _settle(tester, 'back from hidden page'); @@ -189,7 +186,7 @@ void main() { addTearDown(Sleuth.resetStartupForTest); final controller = await _pumpSmall(tester, scale); // A scan opens the app route's session; its rebuild counts feed - // the rebuild-stats banner. + // the More menu's Rebuild stats row. controller.scanTreeFullPathForTest( tester.element( find.ancestor( @@ -205,10 +202,7 @@ void main() { controller.issuesNotifier.value = mixedOverlayIssues(); await _settle(tester, 'open'); - await _tapFinder( - tester, - _semanticsWidget('Startup metrics, tap for details'), - ); + await openFromMoreMenu(tester, 'Startup'); await _settle(tester, 'startup metrics'); expect(find.byType(StartupMetricsPage), findsOneWidget); // Every section, down to the methodology at the bottom. @@ -224,16 +218,7 @@ void main() { await systemBack(tester); await _settle(tester, 'back from startup metrics'); - final total = _rebuildCounts.values.reduce((a, b) => a + b); - await _tapFinder( - tester, - find.text('Rebuilds: $total across ${_rebuildCounts.length} widgets'), - ); - await _settle(tester, 'rebuild banner expanded'); - await _tapFinder( - tester, - _semanticsWidget('See all ${_rebuildCounts.length} rebuilds'), - ); + await openFromMoreMenu(tester, 'Rebuild stats'); await _settle(tester, 'rebuild stats'); expect(find.byType(RebuildStatsPage), findsOneWidget); await tester.scrollUntilVisible( @@ -386,16 +371,10 @@ void main() { ) .height; - testWidgets('a landscape phone keeps the footer and grip on screen', ( - tester, - ) async { + testWidgets('a landscape phone keeps the grip on screen', (tester) async { await pumpView(tester, const Size(640, 360)); expect(tester.takeException(), isNull); expect(cardHeight(tester), lessThanOrEqualTo(340)); - expect( - tester.getRect(find.bySemanticsLabel('Guide')).bottom, - lessThanOrEqualTo(360), - ); expect( tester.getRect(find.bySemanticsLabel('Resize card')).bottom, lessThanOrEqualTo(360), @@ -413,12 +392,16 @@ void main() { ); expect(tester.takeException(), isNull); expect( - tester.getRect(find.bySemanticsLabel('Guide')).bottom, + tester + .getRect( + find.byWidgetPredicate((w) => w is Material && w.elevation == 8), + ) + .bottom, lessThanOrEqualTo(647 - 260), ); }); - testWidgets('a maximized card keeps header, summary bar and footer', ( + testWidgets('a maximized card keeps header, status row and a list row', ( tester, ) async { await pumpView( @@ -428,8 +411,9 @@ void main() { windowState: CardWindowState.maximized, ); expect(tester.takeException(), isNull); - // Header 48 + footer 49 + the summary bar's 48 px hit height. - expect(cardHeight(tester), 48 + 49 + 48); + // Header 48, the status row's 48 px at 1.3x plus its divider, and + // a 48 px list row. + expect(cardHeight(tester), 48 + 48 * 1.3 + 1 + 48); }); testWidgets('resizing stores the unscaled minimum', (tester) async { @@ -464,28 +448,21 @@ void main() { const Size(400, 800), windowState: CardWindowState.maximized, ); - final data = tester - .getSemantics(find.bySemanticsLabel('Resize card')) - .getSemanticsData(); - expect(data.customSemanticsActionIds ?? const [], isEmpty); + // The grip draws its dots only. + expect(find.bySemanticsLabel('Resize card'), findsNothing); handle.dispose(); }); - testWidgets('the issue count stays at the right edge of the status row', ( + testWidgets('the frame rate stays at the right edge of the status row', ( tester, ) async { await _pumpSmall(tester, 1.3); - final count = find.text('${mixedOverlayIssues().length} issues'); + final fps = find.textContaining('fps'); final card = tester.getRect( find.byWidgetPredicate((w) => w is Material && w.elevation == 8), ); - // The count does not fit beside the FPS group and the mode badge. - expect( - tester.getTopLeft(count).dy, - greaterThan(tester.getBottomLeft(find.text('FRAME')).dy), - ); // Status row padding is 12. - expect(tester.getRect(count).right, closeTo(card.right - 12, 0.5)); + expect(tester.getRect(fps).right, closeTo(card.right - 12, 0.5)); expect(tester.takeException(), isNull); }); }); diff --git a/test/ui/text_scaler_clamp_test.dart b/test/ui/text_scaler_clamp_test.dart index 83a65678..1cecdd5c 100644 --- a/test/ui/text_scaler_clamp_test.dart +++ b/test/ui/text_scaler_clamp_test.dart @@ -82,7 +82,7 @@ TextScaler _cardScaler(WidgetTester tester) => /// The status row is chrome, clamped again inside the card. TextScaler _chromeScaler(WidgetTester tester) => - MediaQuery.textScalerOf(tester.element(find.text('FRAME'))); + MediaQuery.textScalerOf(tester.element(find.textContaining('fps'))); void main() { group('clampTextScaler', () { @@ -215,8 +215,7 @@ void main() { // Pages and the Hidden list carry their own chrome clamps. for (final label in ['Encyclopedia', 'Guide']) { - await tester.tap(find.bySemanticsLabel(label)); - await tester.pump(); + await openFromMoreMenu(tester, label); await tester.pump(const Duration(milliseconds: 600)); expect(tester.takeException(), isNull, reason: label); await systemBack(tester); @@ -224,8 +223,7 @@ void main() { } controller.overlayUiState.hide('slow_request'); await tester.pump(); - await tester.tap(find.bySemanticsLabel('1 hidden. Show hidden issues')); - await tester.pump(); + await openFromMoreMenu(tester, 'Hidden issues'); await tester.pump(const Duration(milliseconds: 600)); expect(tester.takeException(), isNull, reason: 'hidden page'); }); From 9fa8c4ee9fa2b2a767cea56ac7fe82ac2b0a2831 Mon Sep 17 00:00:00 2001 From: Harrys76 Date: Mon, 19 Oct 2026 15:14:03 +0700 Subject: [PATCH 2/7] test: overlay build-cost benchmark keeps its issues on a phone-sized view Real scans cleared the seeded issues during the run, and the 800 x 600 surface left the list too little room to expand three cards. The benchmark now runs with no scan, on a 390 x 844 view, and reveals each card before tapping it. On this harness the previous card built 5 issue cards per title change and the new one builds 6, because the smaller chrome leaves the list room for one more row; the budgets are recorded from the new card. Co-Authored-By: Claude Opus 5.5 --- test/benchmark/overlay_build_cost_test.dart | 35 ++++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/test/benchmark/overlay_build_cost_test.dart b/test/benchmark/overlay_build_cost_test.dart index 2816c47a..b201d1ac 100644 --- a/test/benchmark/overlay_build_cost_test.dart +++ b/test/benchmark/overlay_build_cost_test.dart @@ -19,14 +19,16 @@ import '../helpers/overlay_harness.dart'; /// Builds of [FloatingIssuesCard] and of all [IssueCard]s over 50 ticks. typedef _Builds = ({int card, int issueCards}); -/// Recorded before the 1.0.0 card redesign (Flutter 3.47.6, 800 x 600 -/// test surface). A title change rebuilds only the cards the list shows: 4 -/// collapsed or 2 with three expanded. +/// Recorded with the 1.0.0 header, status row and More menu (Flutter +/// 3.47.6, 390 x 844 view). A title change rebuilds each card the list has +/// built, the ones on screen plus the cache extent: 6 per change. The card +/// before the redesign built 5 here, because its chrome left the list less +/// room. const _budgets = { 'collapsed, no change': (card: 0, issueCards: 0), - 'collapsed, title change': (card: 0, issueCards: 200), - 'three expanded, no change': (card: 1, issueCards: 0), - 'three expanded, title change': (card: 1, issueCards: 100), + 'collapsed, title change': (card: 0, issueCards: 300), + 'three expanded, no change': (card: 0, issueCards: 0), + 'three expanded, title change': (card: 0, issueCards: 300), }; const _ticks = 50; @@ -89,7 +91,17 @@ void main() { WidgetTester tester, { required bool expandThree, }) async { - final controller = await pumpOverlay(tester); + // A phone-sized view (iPhone 12 points); on a short surface the + // debug-mode banners leave the list too little room to expand three + // cards. + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + // No real scan runs, so the seeded issues stay for the whole run. + final controller = await pumpOverlay( + tester, + config: const SleuthConfig(treeScanInterval: Duration(hours: 1)), + ); controller.seedIssuesForTest(_issues()); await openDashboard(tester, controller); if (expandThree) { @@ -98,12 +110,19 @@ void main() { card.issue.title, ].take(3); for (final title in titles) { - await tester.ensureVisible(find.text(title)); + // An earlier expansion can push the card out of view. + await tester.ensureVisible(find.text(title, skipOffstage: false)); + await tester.pump(); await tester.tap(find.text(title)); await tester.pump(); } expect(find.byType(IssueCard), findsWidgets); } + // ignore: avoid_print + print( + ' open: ${find.byType(IssueCard).evaluate().length} cards on screen, ' + '${controller.issuesNotifier.value.length} issues', + ); // Lets the new-card accents and the expansion animations finish. await tester.pump(const Duration(seconds: 3)); return controller; From b1aad20c2bd95d973c3dbb4b44863e8edb8a9bbb Mon Sep 17 00:00:00 2001 From: Harrys76 Date: Mon, 19 Oct 2026 15:29:46 +0700 Subject: [PATCH 3/7] fix: keyboard keys reach the overlay; header refits on theme changes The overlay sits above the app's WidgetsApp, so the default shortcuts for Tab, Shift+Tab, Enter and Space never reached it: focus moved into the More menu but could not move on or activate a row. The card's key handler now moves focus and invokes ActivateIntent while focus is in the card or its pages, leaves the keys to a WidgetsApp when one hosts the card, and leaves Enter and Space to a focused text field. The header's measured fit was cached by width, scale and run state only, so a theme change through Sleuth.updateTheme that grew the label font kept the old step and could overflow. The cache key now includes the theme, the text scaler and the inherited text style. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++ lib/src/ui/floating_issues_card.dart | 75 ++++++++++++++++++--- test/debug/overlay_denylist_audit_test.dart | 1 + test/ui/dashboard_run_label_test.dart | 27 ++++++-- test/ui/overlay_focus_test.dart | 45 +++++++++++++ 5 files changed, 138 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99fadc08..f47632e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,10 @@ and screen reader focus to the panel. More, the rows and the theme segments draw a 2 px `checkboxActive` outline while the keyboard has focus. A failed copy offers Try again. +- Tab and Shift+Tab move focus, and Enter and Space activate the focused + control, inside the card and its pages. The overlay sits above the + app's `WidgetsApp`, so the app's default shortcuts never reached it; a + focused text field keeps Enter and Space. - The footer, the startup banner, the rebuild stats banner, the confidence caption and the frame-rate detail row are gone. Hidden issues, Copy snapshot, startup and rebuild stats moved into the More menu. diff --git a/lib/src/ui/floating_issues_card.dart b/lib/src/ui/floating_issues_card.dart index 79f7ddd5..02443a0f 100644 --- a/lib/src/ui/floating_issues_card.dart +++ b/lib/src/ui/floating_issues_card.dart @@ -726,16 +726,59 @@ class _FloatingIssuesCardState extends State /// through `DismissIntent`). With a page or the Hidden list open the /// app holds no focus, and Escape always closes the layer. bool _onKeyEvent(KeyEvent event) { - if (!mounted || - event is! KeyDownEvent || - event.logicalKey != LogicalKeyboardKey.escape) { - return false; + if (!mounted || event is KeyUpEvent) return false; + if (event.logicalKey != LogicalKeyboardKey.escape) { + return _moveOrActivate(event); } + if (event is! KeyDownEvent) return false; if (openLayerDepth == 0 && !_menuOpen && _appTakesEscape()) return false; if (!closeInnermostLayer()) widget.onClose(); return true; } + static final _activateKeys = { + LogicalKeyboardKey.enter, + LogicalKeyboardKey.numpadEnter, + LogicalKeyboardKey.space, + }; + + /// Tab and Shift+Tab move focus, and Enter and Space activate the + /// focused control, while focus is in the card. The overlay sits above + /// the app's `WidgetsApp`, whose default shortcuts therefore never reach + /// it; a card hosted inside a `WidgetsApp` leaves the keys to those + /// shortcuts. A focused text field keeps Enter and Space. + bool _moveOrActivate(KeyEvent event) { + final focus = FocusManager.instance.primaryFocus; + final focusContext = focus?.context; + if (focus == null || + focusContext == null || + !focusContext.mounted || + focusContext.findAncestorStateOfType<_FloatingIssuesCardState>() != + this || + focusContext.findAncestorWidgetOfExactType() != null) { + return false; + } + final key = event.logicalKey; + if (key == LogicalKeyboardKey.tab) { + if (HardwareKeyboard.instance.isShiftPressed) { + focus.previousFocus(); + } else { + focus.nextFocus(); + } + return true; + } + if (event is! KeyDownEvent || + !_activateKeys.contains(key) || + focusContext.findAncestorWidgetOfExactType() != null) { + return false; + } + const intent = ActivateIntent(); + final action = Actions.maybeFind(focusContext); + if (action == null || !action.isEnabled(intent)) return false; + Actions.invoke(focusContext, intent); + return true; + } + /// Whether the app's focused widget handles Escape itself. The route /// lookup subscribes the focused element to its route: 3.32 has no /// route lookup without a dependency, so it runs last. @@ -2335,8 +2378,18 @@ class _FloatingIssuesCardState extends State } /// Measured fit of the header title, cached for one set of inputs: a - /// drag rebuilds the header every frame. - ({double width, double scale, bool vm, bool debug})? _titleFitFor; + /// drag rebuilds the header every frame. The theme and the inherited + /// style are part of the key, since `Sleuth.updateTheme` can change a + /// font size or a spacing while the card is open. + ({ + double width, + TextScaler scaler, + bool vm, + bool debug, + SleuthThemeData theme, + TextStyle inherited, + })? + _titleFitFor; ({bool title, _RunLabelSize label})? _titleFit; /// The paw, "Sleuth" and the run label, stepped down to the width the @@ -2355,17 +2408,19 @@ class _FloatingIssuesCardState extends State return LayoutBuilder( builder: (context, constraints) { final scaler = MediaQuery.textScalerOf(context); + // Text merges the inherited style (Material's letter spacing), so + // the measurement does too. + final inherited = DefaultTextStyle.of(context).style; final key = ( width: constraints.maxWidth, - scale: scaler.scale(theme.fontBase), + scaler: scaler, vm: vmConnected, debug: debug, + theme: theme, + inherited: inherited, ); var fit = _titleFit; if (fit == null || _titleFitFor != key) { - // Text merges the inherited style (Material's letter spacing), - // so the measurement does too. - final inherited = DefaultTextStyle.of(context).style; double measure(String text, TextStyle style) { final painter = TextPainter( text: TextSpan(text: text, style: inherited.merge(style)), diff --git a/test/debug/overlay_denylist_audit_test.dart b/test/debug/overlay_denylist_audit_test.dart index 658e5007..de87837a 100644 --- a/test/debug/overlay_denylist_audit_test.dart +++ b/test/debug/overlay_denylist_audit_test.dart @@ -39,6 +39,7 @@ import 'package:sleuth/src/debug/debug_instrumentation_coordinator.dart'; /// and the denylist fails the audit: classify it here only when it is not /// a widget. const _nonWidgetTypes = { + 'ActivateIntent', 'Alignment', 'AlwaysStoppedAnimation', 'AnimationController', diff --git a/test/ui/dashboard_run_label_test.dart b/test/ui/dashboard_run_label_test.dart index 95b4c181..f8595311 100644 --- a/test/ui/dashboard_run_label_test.dart +++ b/test/ui/dashboard_run_label_test.dart @@ -24,6 +24,7 @@ void main() { bool debug = true, SleuthController? card, double textScale = 1, + SleuthThemeData theme = const SleuthThemeData(), }) async { tester.view.physicalSize = const Size(800, 800); tester.view.devicePixelRatio = 1; @@ -41,10 +42,13 @@ void main() { minScaleFactor: textScale, maxScaleFactor: textScale, child: Scaffold( - body: FloatingIssuesCard( - controller: c, - onClose: () {}, - isDebugMode: debug, + body: SleuthTheme( + data: theme, + child: FloatingIssuesCard( + controller: c, + onClose: () {}, + isDebugMode: debug, + ), ), ), ), @@ -118,6 +122,21 @@ void main() { expect(dot().border, isNull); }); + testWidgets('a larger label font while open steps the header down', ( + tester, + ) async { + await pumpCard(tester, width: 640); + expect(step(), 0); + // Same card, same scale; only the theme's label size changes. + await pumpCard( + tester, + width: 640, + theme: const SleuthThemeData().copyWith(fontXs: 40), + ); + expect(tester.takeException(), isNull); + expect(step(), greaterThan(0)); + }); + testWidgets('reads Profile outside debug mode', (tester) async { await pumpCard(tester, width: 600, debug: false); expect(find.text('Profile · no VM'), findsOneWidget); diff --git a/test/ui/overlay_focus_test.dart b/test/ui/overlay_focus_test.dart index c8994d1d..4067ca22 100644 --- a/test/ui/overlay_focus_test.dart +++ b/test/ui/overlay_focus_test.dart @@ -248,6 +248,51 @@ void main() { expect(app.field.hasPrimaryFocus, isTrue); }); + testWidgets('above the app\'s MaterialApp, Tab moves through the menu ' + 'and Enter opens a row', (tester) async { + final (app, controller) = await _pumpHostApp(tester, config: config); + await openDashboard(tester, controller); + await openMoreMenu(tester); + bool focused(String row) => + Focus.of(tester.element(moreMenuRow(row))).hasPrimaryFocus; + // Startup and Rebuild stats are disabled; Encyclopedia comes first. + expect(focused('Encyclopedia'), isTrue); + + expect(await tester.sendKeyEvent(LogicalKeyboardKey.tab), isTrue); + await tester.pump(); + expect(focused('Guide'), isTrue); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pump(); + expect(focused('Encyclopedia'), isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + + expect(await tester.sendKeyEvent(LogicalKeyboardKey.enter), isTrue); + await tester.pumpAndSettle(); + expect(find.text('Sleuth Guide'), findsOneWidget); + expect(app.pressed, 0); + }); + + testWidgets('Space in an overlay text field is left to the field', ( + tester, + ) async { + final (_, controller) = await _pumpHostApp(tester, config: config); + await openDashboard(tester, controller); + await openFromMoreMenu(tester, 'Encyclopedia'); + await tester.pumpAndSettle(); + await tester.tap( + find.descendant( + of: find.byType(IssueEncyclopediaPage), + matching: find.byType(EditableText), + ), + ); + await tester.pump(); + expect(await tester.sendKeyEvent(LogicalKeyboardKey.space), isFalse); + expect(find.byType(IssueEncyclopediaPage), findsOneWidget); + }); + testWidgets('with the menu open, Escape closes the menu even when an ' 'app text field had focus, then the card', (tester) async { final (app, controller) = await _pumpHostApp(tester, config: config); From a70e1e801ef50eca1e910d660c45c0dd659fdd76 Mon Sep 17 00:00:00 2001 From: Harrys76 Date: Mon, 19 Oct 2026 15:41:34 +0700 Subject: [PATCH 4/7] fix: the issue list stays mounted when "Showing X of Y" comes or goes With a severity off, a scan that adds or removes that severity's last issue flips the "Showing X of Y" row while the shown cards stay the same. The list switched between standing alone and sitting in a Column under the row, so it was rebuilt from scratch: the scroll offset and each card's state were lost. The list now always sits in the Column, keyed, and only the row is inserted or removed. Co-Authored-By: Claude Opus 5.5 --- lib/src/ui/floating_issues_card.dart | 17 +++++++++------- test/ui/overlay_hide_filter_test.dart | 28 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/lib/src/ui/floating_issues_card.dart b/lib/src/ui/floating_issues_card.dart index 02443a0f..af8160e2 100644 --- a/lib/src/ui/floating_issues_card.dart +++ b/lib/src/ui/floating_issues_card.dart @@ -2919,15 +2919,18 @@ class _FloatingIssuesCardState extends State ), ), ); - if (!model.narrowed) return list; + // The list keeps its place in the tree whether or not the row + // shows, so a scan that only flips the row keeps the scroll offset + // and each card's state. return Column( children: [ - _ShowingRow( - shown: visibleIssues.length, - total: model.unfiltered.length, - onShowAll: _showAllSeverities, - ), - Expanded(child: list), + if (model.narrowed) + _ShowingRow( + shown: visibleIssues.length, + total: model.unfiltered.length, + onShowAll: _showAllSeverities, + ), + Expanded(key: const ValueKey('issue-list'), child: list), ], ); } diff --git a/test/ui/overlay_hide_filter_test.dart b/test/ui/overlay_hide_filter_test.dart index 68c50b1d..9a5140a3 100644 --- a/test/ui/overlay_hide_filter_test.dart +++ b/test/ui/overlay_hide_filter_test.dart @@ -788,6 +788,34 @@ void main() { await drainToasts(tester); }); + testWidgets('a scan that only flips the row keeps the list mounted ' + 'and scrolled', (tester) async { + final warnings = [for (var i = 0; i < 20; i++) _issue('w$i')]; + controller.issuesNotifier.value = [ + ...warnings, + _issue('crit', severity: IssueSeverity.critical), + ]; + await pumpCard(tester); + await tapChip(tester, 'critical'); + expect(find.text('Showing 20 of 21'), findsOneWidget); + + final scrollable = find.descendant( + of: find.byType(ListView), + matching: find.byType(Scrollable), + ); + final state = tester.state(scrollable); + state.position.jumpTo(60); + await tester.pump(); + + // The critical issue goes away: the same cards show, the row goes. + controller.issuesNotifier.value = [...warnings]; + await tester.pump(); + expect(find.textContaining('Showing'), findsNothing); + expect(tester.state(scrollable), same(state)); + expect(state.position.pixels, 60); + await drainToasts(tester); + }); + testWidgets('hidden cards do not narrow; the More menu counts them', ( tester, ) async { From 39528c9e8f030b07d0485b96adc1787bde1c1233 Mon Sep 17 00:00:00 2001 From: Harrys76 Date: Mon, 19 Oct 2026 15:54:43 +0700 Subject: [PATCH 5/7] fix: menu records the app focus at each opening; resize starts from the shown width The overlay recorded the app's focused node only at the first menu opening, so after a tap on another app field closed the menu, reopening it kept the old node and closing the dashboard focused that one. Each opening now records the app node focused at that moment; with focus already in the overlay, the earlier node stays. A width stored by an older release (220 px) shows at the 320 px minimum but stayed 220 until the next commit, so the first Wider step or drag computed from 220 and changed nothing. A resize now starts from the stored width clamped to the current minimum and maximum. Co-Authored-By: Claude Opus 5.5 --- lib/src/ui/floating_issues_card.dart | 13 +++++++------ lib/src/ui/sleuth_overlay.dart | 7 +++++-- test/ui/card_resize_test.dart | 21 +++++++++++++++++++++ test/ui/overlay_focus_test.dart | 23 +++++++++++++++++++++++ 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/lib/src/ui/floating_issues_card.dart b/lib/src/ui/floating_issues_card.dart index af8160e2..284ed37f 100644 --- a/lib/src/ui/floating_issues_card.dart +++ b/lib/src/ui/floating_issues_card.dart @@ -2116,14 +2116,15 @@ class _FloatingIssuesCardState extends State void resizeBy(double dw, double dh) { // A right-flush card grows leftwards ([_clampOffset] moves it), up // to the safe-area width minus [_clampOffset]'s 5 px margin, so it - // never reaches under a landscape notch. - _cardWidth = (_cardWidth + dw).clamp( + // never reaches under a landscape notch. The step starts from the + // width shown: a stored width from an older release or a larger + // screen is clamped first. + final maxWidth = math.max( minWidth, - math.max( - minWidth, - screenSize.width - _cachedSafePadding.horizontal - 5, - ), + screenSize.width - _cachedSafePadding.horizontal - 5, ); + final shown = _cardWidth.clamp(minWidth, maxWidth); + _cardWidth = (shown + dw).clamp(minWidth, maxWidth); final stored = _cardHeight ?? cardHeight; final base = dh < 0 ? math.min(stored, cardHeight) : cardHeight; _cardHeight = (base + dh).clamp( diff --git a/lib/src/ui/sleuth_overlay.dart b/lib/src/ui/sleuth_overlay.dart index 8b79ae3a..69eee686 100644 --- a/lib/src/ui/sleuth_overlay.dart +++ b/lib/src/ui/sleuth_overlay.dart @@ -164,8 +164,11 @@ class _SleuthOverlayState extends State _appSemanticsBlocked.value = layers && (host.openLayerDepth > 0 || host.menuOpen); // Reported from the card's build, before the menu's scope takes - // focus, so the app's node is still the primary focus here. - if (layers && host.menuOpen && _appFocusBeforeMenu == null) { + // focus, so the app's node is still the primary focus here. Each + // opening records the app node focused now; with focus already in the + // overlay (More, after an earlier close), the node recorded before + // stays. + if (layers && host.menuOpen) { final focus = FocusManager.instance.primaryFocus; if (focus != null && _isAppFocus(focus)) _appFocusBeforeMenu = focus; } diff --git a/test/ui/card_resize_test.dart b/test/ui/card_resize_test.dart index 49768ff7..5cee7412 100644 --- a/test/ui/card_resize_test.dart +++ b/test/ui/card_resize_test.dart @@ -191,6 +191,27 @@ void main() { expect(controller.overlayUiState.restoreWidth, 320); }); + testWidgets('Wider grows a raised stored width from the width shown', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + controller.overlayUiState.setCardGeometry( + offset: const Offset(100, 100), + width: 220, + height: 400, + windowState: CardWindowState.normal, + ); + await tester.pumpWidget(buildCard()); + expect(findCardConstrainedBox(tester).constraints.maxWidth, 320); + await performCustomAction( + tester, + find.bySemanticsLabel('Resize card'), + 'Wider', + ); + expect(findCardConstrainedBox(tester).constraints.maxWidth, 368); + handle.dispose(); + }); + testWidgets('width clamps at minimum (320 px)', (tester) async { await tester.pumpWidget(buildCard()); diff --git a/test/ui/overlay_focus_test.dart b/test/ui/overlay_focus_test.dart index 4067ca22..0472ebf4 100644 --- a/test/ui/overlay_focus_test.dart +++ b/test/ui/overlay_focus_test.dart @@ -293,6 +293,29 @@ void main() { expect(find.byType(IssueEncyclopediaPage), findsOneWidget); }); + testWidgets('each menu opening records the app node focused then', ( + tester, + ) async { + final (app, controller) = await _pumpHostApp(tester, config: config); + app.button.requestFocus(); + await tester.pump(); + await openDashboard(tester, controller); + await openMoreMenu(tester); + + // A tap on the app's field closes the menu and focuses the field. + await tester.tap(find.byType(TextField)); + await tester.pump(); + expect(find.byType(SleuthMoreMenu), findsNothing); + expect(app.field.hasPrimaryFocus, isTrue); + + await openMoreMenu(tester); + expect(app.field.hasFocus, isFalse); + controller.overlayUiState.dashboardOpen = false; + await tester.pumpAndSettle(); + expect(app.field.hasPrimaryFocus, isTrue); + expect(app.button.hasFocus, isFalse); + }); + testWidgets('with the menu open, Escape closes the menu even when an ' 'app text field had focus, then the card', (tester) async { final (app, controller) = await _pumpHostApp(tester, config: config); From 15ca6a53628eea4cdf724a6534e031ab7b1e585a Mon Sep 17 00:00:00 2001 From: Harrys76 Date: Sat, 24 Oct 2026 00:44:19 +0700 Subject: [PATCH 6/7] fix: chips, the frame rate's node and the Rebuild stats row From an iPhone walk of the 1.0.0 overlay: - Both visible severity chips could be turned off. OK has no chip without OK cards but stayed in the filter, so the list emptied while every chip read off. Turning a chip off is now refused when it would leave no card. Effects that surface once their cause's severity is off count as cards. - The frame rate's Semantics was not a container, so its label landed on the overlay's full-screen node and a screen reader read a screen-sized "60 frames per second". It is now its own node. - Rebuild stats read "No counts yet" in runs that never count rebuilds per route: debug runs, and profile runs without enableDeepDebugInstrumentation. It reads "Off" there (SleuthController.countsRouteRebuilds). Co-Authored-By: Claude Opus 5.5 --- lib/src/controller/sleuth_controller.dart | 11 ++++++++ lib/src/ui/floating_issues_card.dart | 19 ++++++++++++- lib/src/ui/more_menu.dart | 10 +++++-- test/ui/floating_issues_card_test.dart | 29 +++++++++++++++---- test/ui/overlay_hide_filter_test.dart | 34 +++++++++++++++++++++-- test/ui/overlay_semantics_test.dart | 21 ++++++++++++++ 6 files changed, 113 insertions(+), 11 deletions(-) diff --git a/lib/src/controller/sleuth_controller.dart b/lib/src/controller/sleuth_controller.dart index 8e993667..9a4683e3 100644 --- a/lib/src/controller/sleuth_controller.dart +++ b/lib/src/controller/sleuth_controller.dart @@ -935,6 +935,17 @@ class SleuthController { return result; } + /// Whether this run counts rebuilds per route for Rebuild stats: a + /// profile run with [SleuthConfig.enableDeepDebugInstrumentation] whose + /// timeline drain is installed or waiting for the scheduler to go idle. + /// Debug runs count rebuilds for the detectors only. + bool get countsRouteRebuilds { + final coordinator = _debugCoordinator; + if (coordinator == null) return false; + return coordinator.isProfileModeInstalled || + (!kDebugMode && config.enableDeepDebugInstrumentation); + } + /// Whether any heavy debug profiling flags are actually active. /// /// Returns `true` only when at least one Flutter debug flag was saved and diff --git a/lib/src/ui/floating_issues_card.dart b/lib/src/ui/floating_issues_card.dart index 284ed37f..af6a7cbe 100644 --- a/lib/src/ui/floating_issues_card.dart +++ b/lib/src/ui/floating_issues_card.dart @@ -1106,6 +1106,7 @@ class _FloatingIssuesCardState extends State _menuValues = ( ttffMs: Sleuth.startupMetrics?.ttffMs, rebuildTotal: rebuilds > 0 ? rebuilds : null, + rebuildCounting: widget.controller.countsRouteRebuilds, hiddenCount: _ui.hiddenKeys.length + widget.controller.suppressedCountNotifier.value, @@ -1249,7 +1250,21 @@ class _FloatingIssuesCardState extends State } void _toggleSeverity(IssueSeverity severity) { - if (!_ui.toggleSeverity(severity)) { + // A severity with no cards has no chip, so the chips could all read + // off over an empty list. Refuse to turn a chip off when no card would + // be left. Effects that surface once their cause's severity is off + // count as cards. + var refused = false; + if (_ui.severityFilter.contains(severity)) { + final issues = widget.controller.issuesNotifier.value; + final after = applyOverlayFilters( + issues, + severities: {..._ui.severityFilter}..remove(severity), + hiddenKeys: _ui.hiddenKeys, + ); + refused = after.isEmpty && _ui.visibleIssues(issues).isNotEmpty; + } + if (refused || !_ui.toggleSeverity(severity)) { _toast.show('Keep at least one severity', tone: OverlayToastTone.warning); } } @@ -3108,7 +3123,9 @@ class _FpsText extends StatelessWidget { final fps = buffer.throughputFps .clamp(0.0, target.toDouble()) .toStringAsFixed(0); + // A container, or the label lands on the overlay's full-screen node. return Semantics( + container: true, label: warming ? 'Frame rate pending' : '$fps frames per second', excludeSemantics: true, child: Text( diff --git a/lib/src/ui/more_menu.dart b/lib/src/ui/more_menu.dart index dfd7ed78..35c5dab4 100644 --- a/lib/src/ui/more_menu.dart +++ b/lib/src/ui/more_menu.dart @@ -14,6 +14,10 @@ typedef MoreMenuValues = ({ /// Rebuilds counted on the current route, or null when there are none. int? rebuildTotal, + /// Whether the run counts rebuilds per route at all. Without it the + /// Rebuild stats row reads "Off" rather than waiting for counts. + bool rebuildCounting, + /// Issues hidden from the overlay or suppressed by the config. int hiddenCount, }); @@ -156,9 +160,11 @@ class _SleuthMoreMenuState extends State _MenuRow( icon: Icons.autorenew, label: 'Rebuild stats', - value: rebuilds == null + value: rebuilds != null + ? _grouped(rebuilds) + : values.rebuildCounting ? 'No counts yet' - : _grouped(rebuilds), + : 'Off', onTap: rebuilds == null ? null : widget.onRebuildStats, diff --git a/test/ui/floating_issues_card_test.dart b/test/ui/floating_issues_card_test.dart index 833ef6be..78c0ea09 100644 --- a/test/ui/floating_issues_card_test.dart +++ b/test/ui/floating_issues_card_test.dart @@ -51,7 +51,14 @@ PerformanceIssue _pinIssue({ /// `_scanTreeInner` drain → merge block runs against synthetic /// `flutterTimeline`-source data without needing profile-mode compilation. class _FakeCoordinator extends DebugInstrumentationCoordinator { - _FakeCoordinator() : super(installRebuild: false, installPaint: false); + _FakeCoordinator({this.profileMode = false}) + : super(installRebuild: false, installPaint: false); + + /// Stands in for an installed profile-mode timeline drain. + final bool profileMode; + + @override + bool get isProfileModeInstalled => profileMode; DebugSnapshot nextSnapshot = const DebugSnapshot( rebuildCounts: {}, @@ -397,10 +404,8 @@ void main() { find.bySemanticsLabel('Startup, Not measured yet'), findsOneWidget, ); - expect( - find.bySemanticsLabel('Rebuild stats, No counts yet'), - findsOneWidget, - ); + // A debug run counts rebuilds for the detectors only. + expect(find.bySemanticsLabel('Rebuild stats, Off'), findsOneWidget); // No hidden issues: no row. expect(moreMenuRow('Hidden issues'), findsNothing); await tester.tap(moreMenuRow('Startup')); @@ -409,6 +414,20 @@ void main() { expect(find.byType(SleuthMoreMenu), findsOneWidget); }); + testWidgets('Rebuild stats waits for counts in a run that counts them', ( + tester, + ) async { + final c = scanningController(); + c.debugCoordinatorForTest = _FakeCoordinator(profileMode: true); + await tester.pumpWidget(pumpMenuCard(c)); + await openMoreMenu(tester); + + expect( + find.bySemanticsLabel('Rebuild stats, No counts yet'), + findsOneWidget, + ); + }); + testWidgets('Rebuild stats shows the total read at open and opens its ' 'page on that snapshot', (tester) async { final c = scanningController(); diff --git a/test/ui/overlay_hide_filter_test.dart b/test/ui/overlay_hide_filter_test.dart index 9a5140a3..fe4ff1af 100644 --- a/test/ui/overlay_hide_filter_test.dart +++ b/test/ui/overlay_hide_filter_test.dart @@ -1141,6 +1141,26 @@ void main() { await drainToasts(tester); }); + // OK has no chip without OK cards, so it cannot be the one left on. + testWidgets('the last chip that shows cards stays on', (tester) async { + controller.issuesNotifier.value = [ + _issue('w'), + _issue('c', severity: IssueSeverity.critical), + ]; + await pumpCard(tester); + + await tapChip(tester, 'critical'); + await drainToasts(tester); + await tapChip(tester, 'warning'); + expect( + controller.overlayUiState.severityFilter, + containsAll([IssueSeverity.warning, IssueSeverity.ok]), + ); + expect(find.text('Title w'), findsOneWidget); + expect(find.text('Keep at least one severity'), findsOneWidget); + await drainToasts(tester); + }); + testWidgets('toggling with a card expanded clears the expansion without ' 'asserting', (tester) async { controller.issuesNotifier.value = [ @@ -1247,14 +1267,22 @@ void main() { }); testWidgets('nothing matches the severity filter; Reset', (tester) async { - controller.issuesNotifier.value = [_issue('w')]; + controller.issuesNotifier.value = [ + _issue('w'), + _issue('c', severity: IssueSeverity.critical), + ]; await pumpCard(tester); - await tapChip(tester, 'warning'); + await tapChip(tester, 'critical'); + // The warning card goes; only the critical one, filtered out, is left. + controller.issuesNotifier.value = [ + _issue('c', severity: IssueSeverity.critical), + ]; + await tester.pump(); expect(find.text('No issues match the severity filter'), findsOneWidget); await tester.tap(find.bySemanticsLabel('Reset')); await tester.pump(); - expect(find.text('Title w'), findsOneWidget); + expect(find.text('Title c'), findsOneWidget); await drainToasts(tester); }); diff --git a/test/ui/overlay_semantics_test.dart b/test/ui/overlay_semantics_test.dart index 5874d831..137cdc34 100644 --- a/test/ui/overlay_semantics_test.dart +++ b/test/ui/overlay_semantics_test.dart @@ -80,6 +80,27 @@ void main() { }); }); + group('Status row', () { + // A screen reader reads the frame rate where it is drawn, not as a + // node the size of the screen. + for (final withIssues in [true, false]) { + testWidgets('the frame rate has its own node ' + '(${withIssues ? 'chips' : 'no issues'})', (tester) async { + final handle = tester.ensureSemantics(); + final controller = await pumpOverlay(tester); + if (withIssues) controller.issuesNotifier.value = mixedOverlayIssues(); + await openDashboard(tester, controller); + + final node = tester.getSemantics( + find.bySemanticsLabel('Frame rate pending'), + ); + expect(node.label, 'Frame rate pending'); + expect(node.rect.size, tester.getSize(find.text('— fps'))); + handle.dispose(); + }); + } + }); + group('Card header and grip', () { Future pumpCard(WidgetTester tester) async { tester.view.physicalSize = const Size(400, 800); From 0f12137125af7cf8a0104032d08dce088705f6c3 Mon Sep 17 00:00:00 2001 From: Harrys76 Date: Tue, 27 Oct 2026 00:35:08 +0700 Subject: [PATCH 7/7] docs: Theme in the More menu, not a header toggle, in the API docs The theme switch moved from the header to the More menu, where it reads Auto, Light and Dark, but the library doc, Sleuth.updateTheme, SleuthThemeMode, OverlayUiState.themeMode and SleuthThemeData.brightness still described a header toggle. The library's feature list also promised a live FPS chart the overlay does not draw; it shows the frame rate. Co-Authored-By: Claude Opus 5.5 --- lib/sleuth.dart | 14 +++++++------- lib/src/controller/sleuth_controller.dart | 17 ++++++++--------- lib/src/ui/overlay_ui_state.dart | 5 +++-- lib/src/ui/sleuth_overlay.dart | 4 ++-- lib/src/ui/sleuth_theme.dart | 4 ++-- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/lib/sleuth.dart b/lib/sleuth.dart index 4145b477..88e1f624 100644 --- a/lib/sleuth.dart +++ b/lib/sleuth.dart @@ -15,7 +15,7 @@ /// ## Features /// - 21 performance detectors (VM-powered, hybrid, structural, and runtime) /// - Actionable fix hints for every issue -/// - In-app overlay with live FPS chart and issue dashboard +/// - In-app overlay with the frame rate and an issue dashboard /// - Debug mode warning (run with --profile for accurate data) /// - Completely disabled in release builds /// @@ -23,9 +23,9 @@ /// /// The overlay auto-detects dark/light mode from the system brightness and /// uses the high-contrast presets when the platform asks for high -/// contrast. The header toggle cycles System, Light and Dark (persisted in -/// `OverlayUiState.themeMode`). To force a specific theme or customize -/// colors: +/// contrast. Theme in the overlay's More menu picks Auto, Light or Dark +/// (persisted in `OverlayUiState.themeMode`). To force a specific theme or +/// customize colors: /// /// ```dart /// // Light theme for light-background apps @@ -1060,9 +1060,9 @@ class Sleuth { /// Update the overlay theme at runtime. /// /// Passing a [SleuthThemeData] overrides the config theme and - /// auto-detection, and sets the header toggle to System so the theme - /// shows. Picking Light or Dark in the header takes precedence over it; - /// picking System shows it again. Passing `null` reverts to the config + /// auto-detection, and sets Theme in the More menu to Auto so the theme + /// shows. Picking Light or Dark there takes precedence over it; picking + /// Auto shows it again. Passing `null` reverts to the config /// theme or auto-detection. Pass the same instance on every call (a /// const preset or a theme built once): the overlay compares themes by /// identity. diff --git a/lib/src/controller/sleuth_controller.dart b/lib/src/controller/sleuth_controller.dart index 9a4683e3..1ec0e84a 100644 --- a/lib/src/controller/sleuth_controller.dart +++ b/lib/src/controller/sleuth_controller.dart @@ -517,8 +517,8 @@ class SleuthController { ValueNotifier(null); /// Runtime theme override. Takes precedence over [config.theme] and - /// auto-detection when non-null; the header toggle's Light or Dark - /// takes precedence over it. + /// auto-detection when non-null; Light or Dark picked under Theme in + /// the More menu takes precedence over it. final ValueNotifier _themeOverride = ValueNotifier(null); @@ -526,10 +526,9 @@ class SleuthController { /// auto-detect. ValueListenable get themeOverride => _themeOverride; - /// Update the overlay theme at runtime. A non-null [theme] also sets the - /// header toggle to System so the theme shows; picking Light or Dark - /// in the header afterwards takes precedence until System is picked - /// again. An app that calls this at every startup therefore does not + /// Update the overlay theme at runtime. A non-null [theme] also sets + /// Theme in the More menu to Auto so the theme shows; picking Light or + /// Dark there afterwards takes precedence until Auto is picked again. An app that calls this at every startup therefore does not /// keep a saved Light or Dark choice across launches. Pass `null` to /// revert to the config theme or auto-detection. void updateTheme(SleuthThemeData? theme) { @@ -5973,10 +5972,10 @@ class SleuthConfig { /// brightness when `MediaQuery.highContrastOf` is true. If no /// [MediaQuery] is available (rare), defaults to dark. /// - /// Precedence: the header toggle's Light or Dark mode + /// Precedence: Light or Dark picked under Theme in the More menu /// (`OverlayUiState.themeMode`) > `Sleuth.updateTheme` > this theme > - /// auto-selection. Choosing Light or Dark in the header replaces this - /// theme with the Sleuth preset; choosing System restores it. + /// auto-selection. Choosing Light or Dark there replaces this theme with + /// the Sleuth preset; choosing Auto restores it. /// /// ```dart /// // Force light theme diff --git a/lib/src/ui/overlay_ui_state.dart b/lib/src/ui/overlay_ui_state.dart index 6235ca9d..8a87931d 100644 --- a/lib/src/ui/overlay_ui_state.dart +++ b/lib/src/ui/overlay_ui_state.dart @@ -27,7 +27,8 @@ enum CardWindowState { maximized, } -/// Theme mode picked with the overlay header's theme toggle. +/// Theme mode picked with Theme in the overlay's More menu: Auto, Light or +/// Dark. enum SleuthThemeMode { /// Follows `Sleuth.updateTheme`, then `SleuthConfig.theme`, then the /// platform brightness and high-contrast setting. @@ -298,7 +299,7 @@ class OverlayUiState extends ChangeNotifier { // ── Theme ───────────────────────────────────────────────────────────── - /// Theme mode chosen with the header toggle. [SleuthThemeMode.light] and + /// Theme mode chosen with Theme in the More menu. [SleuthThemeMode.light] and /// [SleuthThemeMode.dark] take precedence over a `Sleuth.updateTheme` /// override and `SleuthConfig.theme`; `Sleuth.updateTheme` with a theme /// sets [SleuthThemeMode.system]. diff --git a/lib/src/ui/sleuth_overlay.dart b/lib/src/ui/sleuth_overlay.dart index 69eee686..8feb3ee5 100644 --- a/lib/src/ui/sleuth_overlay.dart +++ b/lib/src/ui/sleuth_overlay.dart @@ -41,8 +41,8 @@ import 'text_scale_clamp.dart'; /// /// Text in the overlay follows the system text scale between 0.8x and /// 2.0x; the app below keeps the unclamped scale. The theme resolves in -/// this order: the header toggle's light or dark -/// [OverlayUiState.themeMode], `Sleuth.updateTheme`, `SleuthConfig.theme`, +/// this order: Light or Dark picked under Theme in the More menu +/// ([OverlayUiState.themeMode]), `Sleuth.updateTheme`, `SleuthConfig.theme`, /// then the platform brightness, with the high-contrast presets when the /// platform asks for high contrast. class SleuthOverlay extends StatefulWidget { diff --git a/lib/src/ui/sleuth_theme.dart b/lib/src/ui/sleuth_theme.dart index 7e77a7cf..025d18ef 100644 --- a/lib/src/ui/sleuth_theme.dart +++ b/lib/src/ui/sleuth_theme.dart @@ -500,8 +500,8 @@ class SleuthThemeData { /// Whether this is a dark or a light theme: the presets set it to match /// their surfaces and [SleuthThemeData.fromColorScheme] to the scheme's - /// surface. Descriptive only; the overlay reads nothing from it. The - /// header toggle icon follows the stored theme mode, and the + /// surface. Descriptive only; the overlay reads nothing from it. Theme + /// in the More menu shows the stored theme mode, and the /// high-contrast preset is picked from that mode or the platform /// brightness. final Brightness brightness;