diff --git a/CHANGELOG.md b/CHANGELOG.md index a344f36b..f47632e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,9 +51,48 @@ - `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. +- 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. +- 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/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 c8aac49f..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) { @@ -935,6 +934,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 @@ -5962,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 @@ -6139,8 +6149,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..af6a7cbe 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( @@ -668,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 (openLayerDepth == 0 && _appTakesEscape()) return false; + 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. @@ -693,6 +794,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 +910,7 @@ class _FloatingIssuesCardState extends State void _minimize() { if (_windowState == CardWindowState.minimized) return; + _closeMenu(refocus: false); setState(() { _preTransitionOffset ??= _cardOffset; _preTransitionWidth ??= _cardWidth; @@ -816,6 +923,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 +955,7 @@ class _FloatingIssuesCardState extends State ); void _restore() { + _closeMenu(refocus: false); setState(() { if (_preTransitionOffset != null) _cardOffset = _preTransitionOffset; if (_preTransitionWidth != null) _cardWidth = _preTransitionWidth!; @@ -897,6 +1006,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 +1077,110 @@ 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, + rebuildCounting: widget.controller.countsRouteRebuilds, + 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,27 +1249,22 @@ 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)) { + // 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); } } @@ -1246,7 +1456,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 +1534,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 +1548,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 +1560,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 +1593,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 +1609,7 @@ class _FloatingIssuesCardState extends State left: clamped.dx, top: clamped.dy, child: Stack( + key: _cardKey, clipBehavior: Clip.none, children: [ _buildCardBody( @@ -1400,6 +1620,7 @@ class _FloatingIssuesCardState extends State chromeScale, clamped, ), + if (_menuOpen && !isMinimized) _menuScrim(theme), if (!isMinimized) _buildResizeHandle( screenSize, @@ -1408,6 +1629,8 @@ class _FloatingIssuesCardState extends State maxAllowedHeight, theme, ), + if (_menuOpen && !isMinimized) + _menuPanel(effectiveWidth, cardHeight, theme, chromeScale), ], ), ), @@ -1570,40 +1793,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 +1819,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 +1845,7 @@ class _FloatingIssuesCardState extends State final offset = _clampOffset( screenSize, safe, - _effectiveWidth(screenSize), + _effectiveWidth(screenSize, safe), _cachedKeyboardHeight, ); var shown = _cachedCardHeight; @@ -1705,18 +1907,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 +1940,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,21 +2108,38 @@ 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, - math.max( - _minCardWidth, - screenSize.width - _cachedSafePadding.horizontal - 5, - ), + // 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, + 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( @@ -1835,10 +2158,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 +2175,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 +2278,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 +2313,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 +2342,7 @@ class _FloatingIssuesCardState extends State ? theme.checkboxActive : theme.textTertiary, onTap: () { + _closeMenu(refocus: false); final newValue = !enabled; widget.controller.highlightEnabledNotifier.value = newValue; @@ -2041,19 +2353,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 +2393,151 @@ class _FloatingIssuesCardState extends State ); } + /// Measured fit of the header title, cached for one set of inputs: a + /// 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 + /// 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); + // 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, + scaler: scaler, + vm: vmConnected, + debug: debug, + theme: theme, + inherited: inherited, + ); + var fit = _titleFit; + if (fit == null || _titleFitFor != key) { + 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 +2631,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 +2654,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 +2725,581 @@ 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)), + ); + }, ), ), ), ); - }, - ); + // 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: [ + if (model.narrowed) + _ShowingRow( + shown: visibleIssues.length, + total: model.unfiltered.length, + onShowAll: _showAllSeverities, + ), + Expanded(key: const ValueKey('issue-list'), 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, - }); +/// 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; + } + } - final Widget lead; - final Widget trail; + /// The reported issues. + final List issues; - /// Smallest horizontal gap between [lead] and [trail] on one line. - final double gap; + /// The cards the list shows. + late final List visible; - @override - Iterable<_LineSlot> get slots => _LineSlot.values; + /// The cards with every severity on and hidden cards left out. + late final List unfiltered; - @override - Widget? childForSlot(_LineSlot slot) => switch (slot) { - _LineSlot.lead => lead, - _LineSlot.trail => trail, - }; + /// The cards with every severity on and nothing hidden. + late final List unhidden; - @override - _RenderLeadTrailLine createRenderObject(BuildContext context) => - _RenderLeadTrailLine(gap); + final Map severityCounts = {}; - @override - void updateRenderObject( - BuildContext context, - _RenderLeadTrailLine renderObject, - ) { - renderObject.gap = gap; - } + /// A severity filter leaves cards out. Hidden cards are counted in the + /// More menu, not here. + bool get narrowed => visible.length < unfiltered.length; } -class _RenderLeadTrailLine extends RenderBox - with SlottedContainerRenderObjectMixin<_LineSlot, RenderBox> { - _RenderLeadTrailLine(this._gap); - - double _gap; - set gap(double value) { - if (value == _gap) return; - _gap = value; - markNeedsLayout(); - } +/// 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, + }); - RenderBox? get _lead => childForSlot(_LineSlot.lead); - RenderBox? get _trail => childForSlot(_LineSlot.trail); + final SleuthController controller; - 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)); - } + /// Chrome text scale (1.0 to 1.3); the row's minimum height grows with + /// it. + final double chromeScale; - @override - Size computeDryLayout(covariant BoxConstraints constraints) => - _layout(constraints, ChildLayoutHelper.dryLayoutChild); + final Map severityCounts; + final Set enabledSeverities; + final ValueChanged onToggleSeverity; - @override - void performLayout() { - size = _layout(constraints, ChildLayoutHelper.layoutChild, position: true); - } + /// Goes on whichever chip comes first. + final GlobalKey firstChipKey; - @override - double computeMinIntrinsicWidth(double height) => math.max( - _lead?.getMinIntrinsicWidth(height) ?? 0, - _trail?.getMinIntrinsicWidth(height) ?? 0, - ); - - @override - double computeMaxIntrinsicWidth(double height) => - (_lead?.getMaxIntrinsicWidth(height) ?? 0) + - _gap + - (_trail?.getMaxIntrinsicWidth(height) ?? 0); - - 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); - } - - @override - double computeMinIntrinsicHeight(double width) => - _intrinsicHeight(width, false); - - @override - double computeMaxIntrinsicHeight(double width) => - _intrinsicHeight(width, true); - - @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); + // 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( + 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 +3444,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, - ), - ), - ), - ), - ), + child: Padding( + padding: EdgeInsets.only( + left: theme.spacingSm, + right: theme.spacingMd, + top: theme.spacingXs, + bottom: theme.spacingXs, ), - ); - }, - ), - ], - ), - ), - ); - } -} - -// ─── 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, + 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()], ), ), - ), - ), - ), - ], - ), - ), - ), - ], - ); - } -} - -/// 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, - ), + ], ), ), ), @@ -3541,128 +3544,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 +3644,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..35c5dab4 --- /dev/null +++ b/lib/src/ui/more_menu.dart @@ -0,0 +1,464 @@ +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, + + /// 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, +}); + +/// 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 + ? _grouped(rebuilds) + : values.rebuildCounting + ? 'No counts yet' + : 'Off', + 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/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/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..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 { @@ -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,26 @@ 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. 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; + } + // 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 +224,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 +498,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 +631,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..025d18ef 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], @@ -495,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; @@ -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/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; 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..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', @@ -60,7 +61,6 @@ const _nonWidgetTypes = { 'GlobalKey', 'HttpException', 'InputDecoration', - 'IntTween', 'Interval', 'LinearGradient', 'LinkedHashSet', @@ -74,7 +74,6 @@ const _nonWidgetTypes = { 'RegExp', 'RoundedRectangleBorder', 'ScrollController', - 'Size', 'StringBuffer', 'TextEditingController', 'TextPainter', @@ -344,6 +343,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..5cee7412 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,83 @@ 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('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()); // 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 +273,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 +303,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 +389,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 +434,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 +455,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 +520,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 +546,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 +609,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..f8595311 --- /dev/null +++ b/test/ui/dashboard_run_label_test.dart @@ -0,0 +1,180 @@ +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, + SleuthThemeData theme = const SleuthThemeData(), + }) 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: SleuthTheme( + data: theme, + child: 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('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); + 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..78c0ea09 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({ @@ -43,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: {}, @@ -142,8 +157,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 +340,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 +350,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 +396,376 @@ 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(); - - // 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; + testWidgets('empty rows read as such and are disabled', (tester) async { + await tester.pumpWidget(pumpMenuCard(controller)); + await openMoreMenu(tester); - await tester.pumpWidget(pumpCardForBanner(controller)); - await tester.pumpAndSettle(); - - await primeAndMergeCounts(tester, controller, fake, {'SoloCard': 3}); - - expect(find.text('Rebuilds: 3 across 1 widget'), findsOneWidget); - }, - ); + expect( + find.bySemanticsLabel('Startup, Not measured 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')); + await tester.pump(); + expect(find.byType(StartupMetricsPage), findsNothing); + expect(find.byType(SleuthMoreMenu), findsOneWidget); + }); - testWidgets('banner renders for low-volume routes (always-on contract)', ( + testWidgets('Rebuild stats waits for counts in a run that counts them', ( 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}, - ), + 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, ); - controller.initializeDetectorsForTest(); + }); + + 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); + 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.ensureVisible(find.byIcon(Icons.pause)); - await tester.tap(find.byIcon(Icons.pause)); - 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); - await tester.pump(); - await tester.ensureVisible(find.byIcon(Icons.pause)); - await tester.tap(find.byIcon(Icons.pause)); + expect((state as OverlayLayerHost).closeInnermostLayer(), isTrue); 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 tester.pump(); - await tester.tap(find.text('Rebuilds: 37 across 4 widgets')); + await openMoreMenu(tester); + await tester.tapAt(const Offset(5, 590)); 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 +1350,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 +1379,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 +1415,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 +1426,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 +1458,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 +1483,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 +1495,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 +1517,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 +1601,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..0472ebf4 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,170 @@ 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('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('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); + 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 +453,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 +462,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..fe4ff1af 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,81 @@ 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('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(find.text('2 confirmed'), findsOneWidget); + 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 { + 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('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 +868,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 +897,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 +925,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 +951,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 +969,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 +1067,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 +1088,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 +1115,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,13 +1135,32 @@ 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); }); + // 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 = [ @@ -1098,8 +1171,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 +1196,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 +1225,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); @@ -1195,15 +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 tester.tap(chip('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); }); @@ -1277,22 +1356,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..137cdc34 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,48 @@ 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('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', () { @@ -92,21 +134,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 +193,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'); });