From 83964498cd96fe0810b167b3dd72462556e7f2ab Mon Sep 17 00:00:00 2001 From: Kenzie Davisson <43759233+kenzieschmoll@users.noreply.github.com> Date: Tue, 25 Jun 2024 15:27:08 -0700 Subject: [PATCH] Move some common widgets and utilities to `devtools_app_shared` (#7979) --- .../lib/src/framework/home_screen.dart | 1 - .../project_root_selection/root_selector.dart | 1 - .../chart/widgets/interval_dropdown.dart | 2 +- .../diff/widgets/classes_table_diff.dart | 1 - .../panes/controls/cpu_profiler_controls.dart | 1 - .../object_inspector_view.dart | 1 - .../lib/src/shared/common_widgets.dart | 228 --------- .../lib/src/shared/file_import.dart | 3 +- .../lib/src/shared/primitives/utils.dart | 26 - .../lib/src/shared/ui/drop_down_button.dart | 1 - .../lib/src/shared/ui/search.dart | 9 +- .../memory/tracing/tracing_view_test.dart | 1 + .../test/shared/primitives/utils_test.dart | 16 - packages/devtools_app_shared/CHANGELOG.md | 11 +- .../lib/src/ui/buttons.dart | 475 ++++++++++++++++++ .../lib/src/ui/common.dart | 409 +-------------- .../lib/src/ui/text_field.dart | 148 ++++++ .../lib/src/utils/utils.dart | 28 ++ packages/devtools_app_shared/lib/ui.dart | 2 + packages/devtools_app_shared/pubspec.yaml | 2 +- .../test/utils/utils_test.dart | 18 + 21 files changed, 713 insertions(+), 671 deletions(-) create mode 100644 packages/devtools_app_shared/lib/src/ui/buttons.dart create mode 100644 packages/devtools_app_shared/lib/src/ui/text_field.dart diff --git a/packages/devtools_app/lib/src/framework/home_screen.dart b/packages/devtools_app/lib/src/framework/home_screen.dart index 32ddd14a229..229a738dc98 100644 --- a/packages/devtools_app/lib/src/framework/home_screen.dart +++ b/packages/devtools_app/lib/src/framework/home_screen.dart @@ -14,7 +14,6 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../shared/analytics/analytics.dart' as ga; import '../shared/analytics/constants.dart' as gac; -import '../shared/common_widgets.dart'; import '../shared/config_specific/import_export/import_export.dart'; import '../shared/connection_info.dart'; import '../shared/globals.dart'; diff --git a/packages/devtools_app/lib/src/screens/deep_link_validation/project_root_selection/root_selector.dart b/packages/devtools_app/lib/src/screens/deep_link_validation/project_root_selection/root_selector.dart index 6639975722f..510c966eea3 100644 --- a/packages/devtools_app/lib/src/screens/deep_link_validation/project_root_selection/root_selector.dart +++ b/packages/devtools_app/lib/src/screens/deep_link_validation/project_root_selection/root_selector.dart @@ -6,7 +6,6 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/material.dart'; -import '../../../shared/common_widgets.dart'; import '../../../shared/primitives/utils.dart'; import '../../../shared/ui/utils.dart'; diff --git a/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/interval_dropdown.dart b/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/interval_dropdown.dart index dab307acfc8..76bc51711db 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/interval_dropdown.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/interval_dropdown.dart @@ -2,11 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:devtools_app_shared/ui.dart'; import 'package:flutter/material.dart'; import '../../../../../shared/analytics/analytics.dart' as ga; import '../../../../../shared/analytics/constants.dart' as gac; -import '../../../../../shared/common_widgets.dart'; import '../controller/chart_pane_controller.dart'; import '../data/primitives.dart'; diff --git a/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/classes_table_diff.dart b/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/classes_table_diff.dart index 204b22ecc11..631d24903fd 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/classes_table_diff.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/classes_table_diff.dart @@ -7,7 +7,6 @@ import 'package:flutter/material.dart'; import '../../../../../shared/analytics/analytics.dart' as ga; import '../../../../../shared/analytics/constants.dart' as gac; -import '../../../../../shared/common_widgets.dart'; import '../../../../../shared/globals.dart'; import '../../../../../shared/memory/classes.dart'; import '../../../../../shared/primitives/utils.dart'; diff --git a/packages/devtools_app/lib/src/screens/profiler/panes/controls/cpu_profiler_controls.dart b/packages/devtools_app/lib/src/screens/profiler/panes/controls/cpu_profiler_controls.dart index 318c43ef177..4eb3d42d83c 100644 --- a/packages/devtools_app/lib/src/screens/profiler/panes/controls/cpu_profiler_controls.dart +++ b/packages/devtools_app/lib/src/screens/profiler/panes/controls/cpu_profiler_controls.dart @@ -7,7 +7,6 @@ import 'dart:developer'; import 'package:devtools_app_shared/ui.dart'; import 'package:flutter/material.dart'; -import '../../../../shared/common_widgets.dart'; import '../../../../shared/globals.dart'; import '../../../../shared/ui/filter.dart'; import '../../cpu_profile_model.dart'; diff --git a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/object_inspector_view.dart b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/object_inspector_view.dart index 8e035534ade..07f9c773e8b 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/object_inspector_view.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/object_inspector_view.dart @@ -9,7 +9,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../../shared/analytics/constants.dart' as gac; -import '../../../shared/common_widgets.dart'; import '../../../shared/ui/drop_down_button.dart'; import '../../debugger/program_explorer.dart'; import '../../debugger/program_explorer_model.dart'; diff --git a/packages/devtools_app/lib/src/shared/common_widgets.dart b/packages/devtools_app/lib/src/shared/common_widgets.dart index 9b76f3c3be0..bd79b497d2e 100644 --- a/packages/devtools_app/lib/src/shared/common_widgets.dart +++ b/packages/devtools_app/lib/src/shared/common_widgets.dart @@ -14,7 +14,6 @@ import 'package:flutter/services.dart'; import 'package:vm_service/vm_service.dart'; import '../screens/debugger/debugger_controller.dart'; -import '../screens/inspector/layout_explorer/ui/theme.dart'; import 'analytics/analytics.dart' as ga; import 'analytics/constants.dart' as gac; import 'config_specific/copy_to_clipboard/copy_to_clipboard.dart'; @@ -726,233 +725,6 @@ class InformationButton extends StatelessWidget { } } -class RoundedCornerOptions { - const RoundedCornerOptions({ - this.showTopLeft = true, - this.showTopRight = true, - this.showBottomLeft = true, - this.showBottomRight = true, - }); - - /// Static constant instance with all borders hidden - static const empty = RoundedCornerOptions( - showTopLeft: false, - showTopRight: false, - showBottomLeft: false, - showBottomRight: false, - ); - - final bool showTopLeft; - final bool showTopRight; - final bool showBottomLeft; - final bool showBottomRight; -} - -class RoundedDropDownButton extends StatelessWidget { - const RoundedDropDownButton({ - super.key, - this.value, - this.onChanged, - this.isDense = false, - this.isExpanded = false, - this.style, - this.selectedItemBuilder, - this.items, - this.roundedCornerOptions, - }); - - final T? value; - - final ValueChanged? onChanged; - - final bool isDense; - - final bool isExpanded; - - final TextStyle? style; - - final DropdownButtonBuilder? selectedItemBuilder; - - final List>? items; - - final RoundedCornerOptions? roundedCornerOptions; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final bgColor = theme.colorScheme.backgroundColorSelected; - - Radius selectRadius(bool show) { - return show ? defaultRadius : Radius.zero; - } - - final style = this.style ?? theme.regularTextStyle; - final showTopLeft = roundedCornerOptions?.showTopLeft ?? true; - final showTopRight = roundedCornerOptions?.showTopRight ?? true; - final showBottomLeft = roundedCornerOptions?.showBottomLeft ?? true; - final showBottomRight = roundedCornerOptions?.showBottomRight ?? true; - - final button = Center( - child: SizedBox( - height: defaultButtonHeight - 2.0, // subtract 2.0 for width of border - child: DropdownButtonHideUnderline( - child: DropdownButton( - padding: const EdgeInsets.only( - left: defaultSpacing, - right: borderPadding, - ), - value: value, - onChanged: onChanged, - isDense: isDense, - isExpanded: isExpanded, - borderRadius: BorderRadius.only( - topLeft: selectRadius(showTopLeft), - topRight: selectRadius(showTopRight), - bottomLeft: selectRadius(showBottomLeft), - bottomRight: selectRadius(showBottomRight), - ), - style: style, - selectedItemBuilder: selectedItemBuilder, - items: items, - focusColor: bgColor, - ), - ), - ), - ); - - if (roundedCornerOptions == RoundedCornerOptions.empty) return button; - - return RoundedOutlinedBorder( - showTopLeft: showTopLeft, - showTopRight: showTopRight, - showBottomLeft: showBottomLeft, - showBottomRight: showBottomRight, - child: button, - ); - } -} - -class DevToolsClearableTextField extends StatelessWidget { - DevToolsClearableTextField({ - super.key, - required this.labelText, - TextEditingController? controller, - this.hintText, - this.prefixIcon, - this.additionalSuffixActions = const [], - this.onChanged, - this.onSubmitted, - this.autofocus = false, - this.enabled, - this.roundedBorder = false, - }) : controller = controller ?? TextEditingController(); - - final TextEditingController controller; - final String? hintText; - final Widget? prefixIcon; - final List additionalSuffixActions; - final String labelText; - final void Function(String)? onChanged; - final void Function(String)? onSubmitted; - final bool autofocus; - final bool? enabled; - final bool roundedBorder; - - static const _contentVerticalPadding = 6.0; - - /// This is the default border radius used by the [OutlineInputBorder] - /// constructor. - static const _defaultInputBorderRadius = - BorderRadius.all(Radius.circular(4.0)); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return SizedBox( - height: defaultTextFieldHeight, - child: TextField( - autofocus: autofocus, - controller: controller, - enabled: enabled, - onChanged: onChanged, - onSubmitted: onSubmitted, - style: theme.regularTextStyle, - decoration: InputDecoration( - isDense: true, - contentPadding: const EdgeInsets.only( - top: _contentVerticalPadding, - bottom: _contentVerticalPadding, - left: denseSpacing, - right: densePadding, - ), - constraints: BoxConstraints( - minHeight: defaultTextFieldHeight, - maxHeight: defaultTextFieldHeight, - ), - border: OutlineInputBorder( - borderRadius: roundedBorder - ? const BorderRadius.all(defaultRadius) - : _defaultInputBorderRadius, - ), - labelText: labelText, - labelStyle: theme.subtleTextStyle, - hintText: hintText, - hintStyle: theme.subtleTextStyle, - prefixIcon: prefixIcon, - suffix: SizedBox( - height: inputDecorationElementHeight, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - clearInputButton( - () { - controller.clear(); - onChanged?.call(''); - }, - ), - ...additionalSuffixActions, - ], - ), - ), - ), - ), - ); - } -} - -Widget clearInputButton(VoidCallback onPressed) { - return inputDecorationSuffixButton( - icon: Icons.clear, - onPressed: onPressed, - tooltip: 'Clear', - ); -} - -Widget closeSearchDropdownButton(VoidCallback? onPressed) { - return inputDecorationSuffixButton(icon: Icons.close, onPressed: onPressed); -} - -Widget inputDecorationSuffixButton({ - required IconData icon, - required VoidCallback? onPressed, - String? tooltip, -}) { - return maybeWrapWithTooltip( - tooltip: tooltip, - child: SizedBox( - height: inputDecorationElementHeight, - width: inputDecorationElementHeight + denseSpacing, - child: IconButton( - padding: EdgeInsets.zero, - onPressed: onPressed, - iconSize: defaultIconSize, - splashRadius: defaultIconSize, - icon: Icon(icon), - ), - ), - ); -} - class OutlinedRowGroup extends StatelessWidget { const OutlinedRowGroup({super.key, required this.children, this.borderColor}); diff --git a/packages/devtools_app/lib/src/shared/file_import.dart b/packages/devtools_app/lib/src/shared/file_import.dart index cb8510c774e..36a4ad1b9f9 100644 --- a/packages/devtools_app/lib/src/shared/file_import.dart +++ b/packages/devtools_app/lib/src/shared/file_import.dart @@ -185,7 +185,8 @@ class _FileImportContainerState extends State { textAlign: TextAlign.left, ), ), - if (importedFile != null) clearInputButton(_clearFile), + if (importedFile != null) + InputDecorationSuffixButton.clear(onPressed: _clearFile), ], ); } diff --git a/packages/devtools_app/lib/src/shared/primitives/utils.dart b/packages/devtools_app/lib/src/shared/primitives/utils.dart index c6272b6f032..b5e453c47f7 100644 --- a/packages/devtools_app/lib/src/shared/primitives/utils.dart +++ b/packages/devtools_app/lib/src/shared/primitives/utils.dart @@ -950,32 +950,6 @@ extension StringExtension on String { ); } - /// Whether [query] is a case insensitive "fuzzy match" for this String. - /// - /// For example, the query "hwf" would be a fuzzy match for the String - /// "hello_world_file". - bool caseInsensitiveFuzzyMatch(String query) { - query = query.toLowerCase(); - final lowercase = toLowerCase(); - final it = query.characters.iterator; - var strIndex = 0; - while (it.moveNext()) { - final char = it.current; - var foundChar = false; - for (int i = strIndex; i < lowercase.length; i++) { - if (lowercase[i] == char) { - strIndex = i + 1; - foundChar = true; - break; - } - } - if (!foundChar) { - return false; - } - } - return true; - } - /// Whether [other] is a case insensitive match for this String. /// /// If [pattern] is a [RegExp], this method will return true if and only if diff --git a/packages/devtools_app/lib/src/shared/ui/drop_down_button.dart b/packages/devtools_app/lib/src/shared/ui/drop_down_button.dart index 4b492cd4e70..044b23b95cf 100644 --- a/packages/devtools_app/lib/src/shared/ui/drop_down_button.dart +++ b/packages/devtools_app/lib/src/shared/ui/drop_down_button.dart @@ -7,7 +7,6 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:flutter/material.dart'; import '../analytics/analytics.dart' as ga; -import '../common_widgets.dart'; /// A [DropDownButton] implementation that reports selection changes to our /// analytics. diff --git a/packages/devtools_app/lib/src/shared/ui/search.dart b/packages/devtools_app/lib/src/shared/ui/search.dart index bce8588f98d..508eb0ca087 100644 --- a/packages/devtools_app/lib/src/shared/ui/search.dart +++ b/packages/devtools_app/lib/src/shared/ui/search.dart @@ -1352,7 +1352,7 @@ class _SearchFieldSuffix extends StatelessWidget { assert(supportsNavigation || onClose != null); return supportsNavigation ? SearchNavigationControls(controller, onClose: onClose) - : closeSearchDropdownButton(onClose); + : InputDecorationSuffixButton.close(onPressed: onClose); } } @@ -1403,15 +1403,16 @@ class SearchNavigationControls extends StatelessWidget { child: PaddedDivider.vertical(), ), ), - inputDecorationSuffixButton( + InputDecorationSuffixButton( icon: Icons.keyboard_arrow_up, onPressed: numMatches > 1 ? controller.previousMatch : null, ), - inputDecorationSuffixButton( + InputDecorationSuffixButton( icon: Icons.keyboard_arrow_down, onPressed: numMatches > 1 ? controller.nextMatch : null, ), - if (onClose != null) closeSearchDropdownButton(onClose), + if (onClose != null) + InputDecorationSuffixButton.close(onPressed: onClose), ], ); }, diff --git a/packages/devtools_app/test/memory/tracing/tracing_view_test.dart b/packages/devtools_app/test/memory/tracing/tracing_view_test.dart index 5fc7286d4bd..7222328a0e7 100644 --- a/packages/devtools_app/test/memory/tracing/tracing_view_test.dart +++ b/packages/devtools_app/test/memory/tracing/tracing_view_test.dart @@ -9,6 +9,7 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/src/screens/memory/framework/memory_tabs.dart'; import 'package:devtools_app/src/screens/memory/panes/tracing/tracing_pane_controller.dart'; import 'package:devtools_app/src/screens/memory/panes/tracing/tracing_tree.dart'; +import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; diff --git a/packages/devtools_app/test/shared/primitives/utils_test.dart b/packages/devtools_app/test/shared/primitives/utils_test.dart index 6daea5c4abc..2b5fe4733be 100644 --- a/packages/devtools_app/test/shared/primitives/utils_test.dart +++ b/packages/devtools_app/test/shared/primitives/utils_test.dart @@ -1144,22 +1144,6 @@ void main() { }); group('StringExtension', () { - test('fuzzyMatch', () { - const str = 'hello_world_file'; - expect(str.caseInsensitiveFuzzyMatch('h'), isTrue); - expect(str.caseInsensitiveFuzzyMatch('o_'), isTrue); - expect(str.caseInsensitiveFuzzyMatch('hw'), isTrue); - expect(str.caseInsensitiveFuzzyMatch('hwf'), isTrue); - expect(str.caseInsensitiveFuzzyMatch('_e'), isTrue); - expect(str.caseInsensitiveFuzzyMatch('HWF'), isTrue); - expect(str.caseInsensitiveFuzzyMatch('_E'), isTrue); - - expect(str.caseInsensitiveFuzzyMatch('hwfh'), isFalse); - expect(str.caseInsensitiveFuzzyMatch('hfw'), isFalse); - expect(str.caseInsensitiveFuzzyMatch('gello'), isFalse); - expect(str.caseInsensitiveFuzzyMatch('files'), isFalse); - }); - test('caseInsensitiveContains', () { const str = 'This is a test string with a path/to/uri'; expect(str.caseInsensitiveContains('test'), isTrue); diff --git a/packages/devtools_app_shared/CHANGELOG.md b/packages/devtools_app_shared/CHANGELOG.md index fa87159ca70..cc1c775a5eb 100644 --- a/packages/devtools_app_shared/CHANGELOG.md +++ b/packages/devtools_app_shared/CHANGELOG.md @@ -1,4 +1,9 @@ -## 0.2.0 +## 0.2.2-wip +* Add `caseInsensitiveFuzzyMatch` extension method on `String`. +* Add common widgets `DevToolsClearableTextField`, `InputDecorationSuffixButton`, +and `RoundedDropDownButton`. + +## 0.2.1 * Add `navigateToCode` utility method for jumping to code in IDEs. * Add `FlutterEvent` and `DeveloperServiceEvent` constants. * Add `connectedAppPackageRoot`, `rootPackageDirectoryForMainIsolate`, and @@ -21,11 +26,11 @@ launching a URL in the browser, and includes special handling for launching URLs when in an embedded VS Code view. ## 0.1.1 -* Update `package:dtd` to `^2.1.0` +* Update `package:dtd` to `^2.1.0`. * Add `DTDManager.projectRoots` method. * Bump the minimum Dart and Flutter SDK versions to `3.4.0-282.1.beta` and `3.22.0-0.1.pre` respectively. -* Bump `devtools_shared` to ^8.1.1-dev.0 +* Bump `devtools_shared` to ^8.1.1-dev.0. ## 0.1.0 * Remove deprecated `background` and `onBackground` values for `lightColorScheme` diff --git a/packages/devtools_app_shared/lib/src/ui/buttons.dart b/packages/devtools_app_shared/lib/src/ui/buttons.dart new file mode 100644 index 00000000000..12e3072cd71 --- /dev/null +++ b/packages/devtools_app_shared/lib/src/ui/buttons.dart @@ -0,0 +1,475 @@ +// Copyright 2024 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +import 'common.dart'; +import 'theme/theme.dart'; + +/// A button with default DevTools styling and analytics handling. +/// +/// * `onPressed`: The callback to be called upon pressing the button. +/// * `minScreenWidthForTextBeforeScaling`: The minimum width the button can be before the text is +/// omitted. +class DevToolsButton extends StatelessWidget { + const DevToolsButton({ + super.key, + required this.onPressed, + this.icon, + this.label, + this.tooltip, + this.color, + this.minScreenWidthForTextBeforeScaling, + this.elevated = false, + this.outlined = true, + this.tooltipPadding, + }) : assert( + label != null || icon != null, + 'Either icon or label must be specified.', + ); + + factory DevToolsButton.iconOnly({ + required IconData icon, + String? tooltip, + VoidCallback? onPressed, + bool outlined = true, + }) { + return DevToolsButton( + icon: icon, + outlined: outlined, + tooltip: tooltip, + onPressed: onPressed, + ); + } + + final IconData? icon; + + final String? label; + + final String? tooltip; + + final Color? color; + + final VoidCallback? onPressed; + + final double? minScreenWidthForTextBeforeScaling; + + /// Whether this icon label button should use an elevated button style. + final bool elevated; + + /// Whether this icon label button should use an outlined button style. + final bool outlined; + + final EdgeInsetsGeometry? tooltipPadding; + + @override + Widget build(BuildContext context) { + var tooltip = this.tooltip; + + if (label == null) { + return SizedBox( + // This is required to force the button size. + height: defaultButtonHeight, + width: defaultButtonHeight, + child: maybeWrapWithTooltip( + tooltip: tooltip, + tooltipPadding: tooltipPadding, + child: outlined + ? IconButton.outlined( + onPressed: onPressed, + iconSize: defaultIconSize, + icon: Icon(icon), + ) + : IconButton( + onPressed: onPressed, + iconSize: defaultIconSize, + icon: Icon(icon), + ), + ), + ); + } + final colorScheme = Theme.of(context).colorScheme; + var textColor = color; + if (textColor == null && elevated) { + textColor = + onPressed == null ? colorScheme.onSurface : colorScheme.onPrimary; + } + final iconLabel = MaterialIconLabel( + label: label!, + iconData: icon, + minScreenWidthForTextBeforeScaling: minScreenWidthForTextBeforeScaling, + color: textColor, + ); + + // If we hid the label due to a small screen width and the button does not + // have a tooltip, use the label as a tooltip. + final labelHidden = + !isScreenWiderThan(context, minScreenWidthForTextBeforeScaling); + if (labelHidden && tooltip == null) { + tooltip = label; + } + + if (elevated) { + return SizedBox( + // This is required to force the button size. + height: defaultButtonHeight, + child: maybeWrapWithTooltip( + tooltip: tooltip, + tooltipPadding: tooltipPadding, + child: ElevatedButton( + onPressed: onPressed, + child: iconLabel, + ), + ), + ); + } + // TODO(kenz): this SizedBox wrapper should be unnecessary once + // https://github.com/flutter/flutter/issues/79894 is fixed. + return maybeWrapWithTooltip( + tooltip: tooltip, + tooltipPadding: tooltipPadding, + child: SizedBox( + height: defaultButtonHeight, + width: !isScreenWiderThan(context, minScreenWidthForTextBeforeScaling) + ? buttonMinWidth + : null, + child: outlined + ? OutlinedButton( + style: denseAwareOutlinedButtonStyle( + context, + minScreenWidthForTextBeforeScaling, + ), + onPressed: onPressed, + child: iconLabel, + ) + : TextButton( + onPressed: onPressed, + style: denseAwareTextButtonStyle( + context, + minScreenWidthForTextBeforeScaling: + minScreenWidthForTextBeforeScaling, + ), + child: iconLabel, + ), + ), + ); + } +} + +final class DevToolsToggleButtonGroup extends StatelessWidget { + const DevToolsToggleButtonGroup({ + super.key, + required this.children, + required this.selectedStates, + required this.onPressed, + this.fillColor, + this.selectedColor, + this.borderColor, + }); + + final List children; + + final List selectedStates; + + final void Function(int)? onPressed; + + final Color? fillColor; + + final Color? selectedColor; + + final Color? borderColor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SizedBox( + height: defaultButtonHeight, + child: ToggleButtons( + borderRadius: defaultBorderRadius, + fillColor: fillColor, + selectedColor: selectedColor, + borderColor: borderColor, + textStyle: theme.textTheme.bodyMedium, + constraints: BoxConstraints( + minWidth: defaultButtonHeight, + minHeight: defaultButtonHeight, + ), + isSelected: selectedStates, + onPressed: onPressed, + children: children, + ), + ); + } +} + +final class DevToolsToggleButton extends StatelessWidget { + const DevToolsToggleButton({ + super.key, + required this.onPressed, + required this.isSelected, + required this.message, + required this.icon, + this.outlined = true, + this.label, + this.shape, + }); + + final String message; + + final VoidCallback onPressed; + + final bool isSelected; + + final IconData icon; + + final String? label; + + final OutlinedBorder? shape; + + final bool outlined; + + @override + Widget build(BuildContext context) { + return DevToolsToggleButtonGroup( + borderColor: outlined || isSelected + ? Theme.of(context).focusColor + : Colors.transparent, + selectedStates: [isSelected], + onPressed: (_) => onPressed(), + children: [ + DevToolsTooltip( + message: message, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: denseSpacing), + child: MaterialIconLabel( + iconData: icon, + label: label, + ), + ), + ), + ], + ); + } +} + +/// A group of buttons that share a common border. +/// +/// This widget ensures the buttons are displayed with proper borders on the +/// interior and exterior of the group. The attirbutes for each button can be +/// defined by [ButtonGroupItemData] and included in [items]. +final class RoundedButtonGroup extends StatelessWidget { + const RoundedButtonGroup({ + super.key, + required this.items, + this.minScreenWidthForTextBeforeScaling, + }); + + final List items; + final double? minScreenWidthForTextBeforeScaling; + + @override + Widget build(BuildContext context) { + Widget buildButton(int index) { + final itemData = items[index]; + Widget button = _ButtonGroupButton( + buttonData: itemData, + roundedLeftBorder: index == 0, + roundedRightBorder: index == items.length - 1, + minScreenWidthForTextBeforeScaling: minScreenWidthForTextBeforeScaling, + ); + if (index != 0) { + button = Container( + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: Theme.of(context).focusColor, + ), + ), + ), + child: button, + ); + } + return button; + } + + return SizedBox( + height: defaultButtonHeight, + child: RoundedOutlinedBorder( + child: Row( + children: [ + for (int i = 0; i < items.length; i++) buildButton(i), + ], + ), + ), + ); + } +} + +final class _ButtonGroupButton extends StatelessWidget { + const _ButtonGroupButton({ + required this.buttonData, + this.roundedLeftBorder = false, + this.roundedRightBorder = false, + this.minScreenWidthForTextBeforeScaling, + }); + + final ButtonGroupItemData buttonData; + final bool roundedLeftBorder; + final bool roundedRightBorder; + final double? minScreenWidthForTextBeforeScaling; + + @override + Widget build(BuildContext context) { + return DevToolsTooltip( + message: buttonData.tooltip, + child: OutlinedButton( + autofocus: buttonData.autofocus, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: densePadding), + side: BorderSide.none, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.horizontal( + left: roundedLeftBorder ? defaultRadius : Radius.zero, + right: roundedRightBorder ? defaultRadius : Radius.zero, + ), + ), + ), + onPressed: buttonData.onPressed, + child: MaterialIconLabel( + label: buttonData.label, + iconData: buttonData.icon, + minScreenWidthForTextBeforeScaling: + minScreenWidthForTextBeforeScaling, + ), + ), + ); + } +} + +final class ButtonGroupItemData { + const ButtonGroupItemData({ + this.label, + this.icon, + String? tooltip, + this.onPressed, + this.autofocus = false, + }) : tooltip = tooltip ?? label, + assert(label != null || icon != null); + + final String? label; + final IconData? icon; + final String? tooltip; + final VoidCallback? onPressed; + final bool autofocus; +} + +final class DevToolsFilterButton extends StatelessWidget { + const DevToolsFilterButton({ + super.key, + required this.onPressed, + required this.isFilterActive, + this.message = 'Filter', + this.outlined = true, + }); + + final VoidCallback onPressed; + final bool isFilterActive; + final String message; + final bool outlined; + + @override + Widget build(BuildContext context) { + return DevToolsToggleButton( + onPressed: onPressed, + isSelected: isFilterActive, + message: message, + icon: Icons.filter_list, + outlined: outlined, + ); + } +} + +/// A DevTools-styled dropdown button. +final class RoundedDropDownButton extends StatelessWidget { + const RoundedDropDownButton({ + super.key, + this.value, + this.onChanged, + this.isDense = false, + this.isExpanded = false, + this.style, + this.selectedItemBuilder, + this.items, + this.roundedCornerOptions, + }); + + final T? value; + + final ValueChanged? onChanged; + + final bool isDense; + + final bool isExpanded; + + final TextStyle? style; + + final DropdownButtonBuilder? selectedItemBuilder; + + final List>? items; + + final RoundedCornerOptions? roundedCornerOptions; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + Radius selectRadius(bool show) { + return show ? defaultRadius : Radius.zero; + } + + final style = this.style ?? theme.regularTextStyle; + final showTopLeft = roundedCornerOptions?.showTopLeft ?? true; + final showTopRight = roundedCornerOptions?.showTopRight ?? true; + final showBottomLeft = roundedCornerOptions?.showBottomLeft ?? true; + final showBottomRight = roundedCornerOptions?.showBottomRight ?? true; + + final button = Center( + child: SizedBox( + height: defaultButtonHeight - 2.0, // subtract 2.0 for width of border + child: DropdownButtonHideUnderline( + child: DropdownButton( + padding: const EdgeInsets.only( + left: defaultSpacing, + right: borderPadding, + ), + value: value, + onChanged: onChanged, + isDense: isDense, + isExpanded: isExpanded, + borderRadius: BorderRadius.only( + topLeft: selectRadius(showTopLeft), + topRight: selectRadius(showTopRight), + bottomLeft: selectRadius(showBottomLeft), + bottomRight: selectRadius(showBottomRight), + ), + style: style, + selectedItemBuilder: selectedItemBuilder, + items: items, + focusColor: theme.colorScheme.surface, + ), + ), + ), + ); + + if (roundedCornerOptions == RoundedCornerOptions.empty) return button; + + return RoundedOutlinedBorder( + showTopLeft: showTopLeft, + showTopRight: showTopRight, + showBottomLeft: showBottomLeft, + showBottomRight: showBottomRight, + child: button, + ); + } +} diff --git a/packages/devtools_app_shared/lib/src/ui/common.dart b/packages/devtools_app_shared/lib/src/ui/common.dart index 3306447dd76..77db9c24a16 100644 --- a/packages/devtools_app_shared/lib/src/ui/common.dart +++ b/packages/devtools_app_shared/lib/src/ui/common.dart @@ -312,158 +312,8 @@ final class PaddedDivider extends StatelessWidget { } } -/// A button with default DevTools styling and analytics handling. -/// -/// * `onPressed`: The callback to be called upon pressing the button. -/// * `minScreenWidthForTextBeforeScaling`: The minimum width the button can be before the text is -/// omitted. -class DevToolsButton extends StatelessWidget { - const DevToolsButton({ - super.key, - required this.onPressed, - this.icon, - this.label, - this.tooltip, - this.color, - this.minScreenWidthForTextBeforeScaling, - this.elevated = false, - this.outlined = true, - this.tooltipPadding, - }) : assert( - label != null || icon != null, - 'Either icon or label must be specified.', - ); - - factory DevToolsButton.iconOnly({ - required IconData icon, - String? tooltip, - VoidCallback? onPressed, - bool outlined = true, - }) { - return DevToolsButton( - icon: icon, - outlined: outlined, - tooltip: tooltip, - onPressed: onPressed, - ); - } - - final IconData? icon; - - final String? label; - - final String? tooltip; - - final Color? color; - - final VoidCallback? onPressed; - - final double? minScreenWidthForTextBeforeScaling; - - /// Whether this icon label button should use an elevated button style. - final bool elevated; - - /// Whether this icon label button should use an outlined button style. - final bool outlined; - - final EdgeInsetsGeometry? tooltipPadding; - - @override - Widget build(BuildContext context) { - var tooltip = this.tooltip; - - if (label == null) { - return SizedBox( - // This is required to force the button size. - height: defaultButtonHeight, - width: defaultButtonHeight, - child: maybeWrapWithTooltip( - tooltip: tooltip, - tooltipPadding: tooltipPadding, - child: outlined - ? IconButton.outlined( - onPressed: onPressed, - iconSize: defaultIconSize, - icon: Icon(icon), - ) - : IconButton( - onPressed: onPressed, - iconSize: defaultIconSize, - icon: Icon(icon), - ), - ), - ); - } - final colorScheme = Theme.of(context).colorScheme; - var textColor = color; - if (textColor == null && elevated) { - textColor = - onPressed == null ? colorScheme.onSurface : colorScheme.onPrimary; - } - final iconLabel = MaterialIconLabel( - label: label!, - iconData: icon, - minScreenWidthForTextBeforeScaling: minScreenWidthForTextBeforeScaling, - color: textColor, - ); - - // If we hid the label due to a small screen width and the button does not - // have a tooltip, use the label as a tooltip. - final labelHidden = - !isScreenWiderThan(context, minScreenWidthForTextBeforeScaling); - if (labelHidden && tooltip == null) { - tooltip = label; - } - - if (elevated) { - return SizedBox( - // This is required to force the button size. - height: defaultButtonHeight, - child: maybeWrapWithTooltip( - tooltip: tooltip, - tooltipPadding: tooltipPadding, - child: ElevatedButton( - onPressed: onPressed, - child: iconLabel, - ), - ), - ); - } - // TODO(kenz): this SizedBox wrapper should be unnecessary once - // https://github.com/flutter/flutter/issues/79894 is fixed. - return maybeWrapWithTooltip( - tooltip: tooltip, - tooltipPadding: tooltipPadding, - child: SizedBox( - height: defaultButtonHeight, - width: !isScreenWiderThan(context, minScreenWidthForTextBeforeScaling) - ? buttonMinWidth - : null, - child: outlined - ? OutlinedButton( - style: denseAwareOutlinedButtonStyle( - context, - minScreenWidthForTextBeforeScaling, - ), - onPressed: onPressed, - child: iconLabel, - ) - : TextButton( - onPressed: onPressed, - style: denseAwareTextButtonStyle( - context, - minScreenWidthForTextBeforeScaling: - minScreenWidthForTextBeforeScaling, - ), - child: iconLabel, - ), - ), - ); - } -} - -/// A widget, commonly used for icon buttons, that provides a tooltip with a -/// common delay before the tooltip is shown. +/// A widget that provides a tooltip with a common delay before the tooltip is +/// shown. final class DevToolsTooltip extends StatelessWidget { const DevToolsTooltip({ super.key, @@ -511,239 +361,6 @@ final class DevToolsTooltip extends StatelessWidget { } } -final class DevToolsToggleButtonGroup extends StatelessWidget { - const DevToolsToggleButtonGroup({ - super.key, - required this.children, - required this.selectedStates, - required this.onPressed, - this.fillColor, - this.selectedColor, - this.borderColor, - }); - - final List children; - - final List selectedStates; - - final void Function(int)? onPressed; - - final Color? fillColor; - - final Color? selectedColor; - - final Color? borderColor; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return SizedBox( - height: defaultButtonHeight, - child: ToggleButtons( - borderRadius: defaultBorderRadius, - fillColor: fillColor, - selectedColor: selectedColor, - borderColor: borderColor, - textStyle: theme.textTheme.bodyMedium, - constraints: BoxConstraints( - minWidth: defaultButtonHeight, - minHeight: defaultButtonHeight, - ), - isSelected: selectedStates, - onPressed: onPressed, - children: children, - ), - ); - } -} - -final class DevToolsToggleButton extends StatelessWidget { - const DevToolsToggleButton({ - super.key, - required this.onPressed, - required this.isSelected, - required this.message, - required this.icon, - this.outlined = true, - this.label, - this.shape, - }); - - final String message; - - final VoidCallback onPressed; - - final bool isSelected; - - final IconData icon; - - final String? label; - - final OutlinedBorder? shape; - - final bool outlined; - - @override - Widget build(BuildContext context) { - return DevToolsToggleButtonGroup( - borderColor: outlined || isSelected - ? Theme.of(context).focusColor - : Colors.transparent, - selectedStates: [isSelected], - onPressed: (_) => onPressed(), - children: [ - DevToolsTooltip( - message: message, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: denseSpacing), - child: MaterialIconLabel( - iconData: icon, - label: label, - ), - ), - ), - ], - ); - } -} - -/// A group of buttons that share a common border. -/// -/// This widget ensures the buttons are displayed with proper borders on the -/// interior and exterior of the group. The attirbutes for each button can be -/// defined by [ButtonGroupItemData] and included in [items]. -final class RoundedButtonGroup extends StatelessWidget { - const RoundedButtonGroup({ - super.key, - required this.items, - this.minScreenWidthForTextBeforeScaling, - }); - - final List items; - final double? minScreenWidthForTextBeforeScaling; - - @override - Widget build(BuildContext context) { - Widget buildButton(int index) { - final itemData = items[index]; - Widget button = _ButtonGroupButton( - buttonData: itemData, - roundedLeftBorder: index == 0, - roundedRightBorder: index == items.length - 1, - minScreenWidthForTextBeforeScaling: minScreenWidthForTextBeforeScaling, - ); - if (index != 0) { - button = Container( - decoration: BoxDecoration( - border: Border( - left: BorderSide( - color: Theme.of(context).focusColor, - ), - ), - ), - child: button, - ); - } - return button; - } - - return SizedBox( - height: defaultButtonHeight, - child: RoundedOutlinedBorder( - child: Row( - children: [ - for (int i = 0; i < items.length; i++) buildButton(i), - ], - ), - ), - ); - } -} - -final class _ButtonGroupButton extends StatelessWidget { - const _ButtonGroupButton({ - required this.buttonData, - this.roundedLeftBorder = false, - this.roundedRightBorder = false, - this.minScreenWidthForTextBeforeScaling, - }); - - final ButtonGroupItemData buttonData; - final bool roundedLeftBorder; - final bool roundedRightBorder; - final double? minScreenWidthForTextBeforeScaling; - - @override - Widget build(BuildContext context) { - return DevToolsTooltip( - message: buttonData.tooltip, - child: OutlinedButton( - autofocus: buttonData.autofocus, - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: densePadding), - side: BorderSide.none, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.horizontal( - left: roundedLeftBorder ? defaultRadius : Radius.zero, - right: roundedRightBorder ? defaultRadius : Radius.zero, - ), - ), - ), - onPressed: buttonData.onPressed, - child: MaterialIconLabel( - label: buttonData.label, - iconData: buttonData.icon, - minScreenWidthForTextBeforeScaling: - minScreenWidthForTextBeforeScaling, - ), - ), - ); - } -} - -final class ButtonGroupItemData { - const ButtonGroupItemData({ - this.label, - this.icon, - String? tooltip, - this.onPressed, - this.autofocus = false, - }) : tooltip = tooltip ?? label, - assert(label != null || icon != null); - - final String? label; - final IconData? icon; - final String? tooltip; - final VoidCallback? onPressed; - final bool autofocus; -} - -final class DevToolsFilterButton extends StatelessWidget { - const DevToolsFilterButton({ - super.key, - required this.onPressed, - required this.isFilterActive, - this.message = 'Filter', - this.outlined = true, - }); - - final VoidCallback onPressed; - final bool isFilterActive; - final String message; - final bool outlined; - - @override - Widget build(BuildContext context) { - return DevToolsToggleButton( - onPressed: onPressed, - isSelected: isFilterActive, - message: message, - icon: Icons.filter_list, - outlined: outlined, - ); - } -} - /// Label including an image icon and optional text. final class ImageIconLabel extends StatelessWidget { const ImageIconLabel( @@ -923,3 +540,25 @@ class Link { final String display; final String url; } + +class RoundedCornerOptions { + const RoundedCornerOptions({ + this.showTopLeft = true, + this.showTopRight = true, + this.showBottomLeft = true, + this.showBottomRight = true, + }); + + /// Static constant instance with all borders hidden + static const empty = RoundedCornerOptions( + showTopLeft: false, + showTopRight: false, + showBottomLeft: false, + showBottomRight: false, + ); + + final bool showTopLeft; + final bool showTopRight; + final bool showBottomLeft; + final bool showBottomRight; +} diff --git a/packages/devtools_app_shared/lib/src/ui/text_field.dart b/packages/devtools_app_shared/lib/src/ui/text_field.dart new file mode 100644 index 00000000000..2839a0028f0 --- /dev/null +++ b/packages/devtools_app_shared/lib/src/ui/text_field.dart @@ -0,0 +1,148 @@ +// Copyright 2024 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +import 'common.dart'; +import 'theme/theme.dart'; + +/// A DevTools-styled text field with a suffix action to clear the search field. +final class DevToolsClearableTextField extends StatelessWidget { + DevToolsClearableTextField({ + super.key, + required this.labelText, + TextEditingController? controller, + this.hintText, + this.prefixIcon, + this.additionalSuffixActions = const [], + this.onChanged, + this.onSubmitted, + this.autofocus = false, + this.enabled, + this.roundedBorder = false, + }) : controller = controller ?? TextEditingController(); + + final TextEditingController controller; + final String? hintText; + final Widget? prefixIcon; + final List additionalSuffixActions; + final String labelText; + final void Function(String)? onChanged; + final void Function(String)? onSubmitted; + final bool autofocus; + final bool? enabled; + final bool roundedBorder; + + static const _contentVerticalPadding = 6.0; + + /// This is the default border radius used by the [OutlineInputBorder] + /// constructor. + static const _defaultInputBorderRadius = + BorderRadius.all(Radius.circular(4.0)); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SizedBox( + height: defaultTextFieldHeight, + child: TextField( + autofocus: autofocus, + controller: controller, + enabled: enabled, + onChanged: onChanged, + onSubmitted: onSubmitted, + style: theme.regularTextStyle, + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.only( + top: _contentVerticalPadding, + bottom: _contentVerticalPadding, + left: denseSpacing, + right: densePadding, + ), + constraints: BoxConstraints( + minHeight: defaultTextFieldHeight, + maxHeight: defaultTextFieldHeight, + ), + border: OutlineInputBorder( + borderRadius: roundedBorder + ? const BorderRadius.all(defaultRadius) + : _defaultInputBorderRadius, + ), + labelText: labelText, + labelStyle: theme.subtleTextStyle, + hintText: hintText, + hintStyle: theme.subtleTextStyle, + prefixIcon: prefixIcon, + suffix: SizedBox( + height: inputDecorationElementHeight, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + InputDecorationSuffixButton.clear( + onPressed: () { + controller.clear(); + onChanged?.call(''); + }, + ), + ...additionalSuffixActions, + ], + ), + ), + ), + ), + ); + } +} + +/// A DevTools-styled icon action button intended to be used as an +/// [InputDecoration.suffix] widget. +final class InputDecorationSuffixButton extends StatelessWidget { + const InputDecorationSuffixButton({ + super.key, + required this.icon, + required this.onPressed, + this.tooltip, + }); + + factory InputDecorationSuffixButton.clear({ + required VoidCallback? onPressed, + }) => + InputDecorationSuffixButton( + icon: Icons.clear, + onPressed: onPressed, + tooltip: 'Clear', + ); + + factory InputDecorationSuffixButton.close({ + required VoidCallback? onPressed, + }) => + InputDecorationSuffixButton( + icon: Icons.close, + onPressed: onPressed, + tooltip: 'Close', + ); + + final IconData icon; + final VoidCallback? onPressed; + final String? tooltip; + + @override + Widget build(BuildContext context) { + return maybeWrapWithTooltip( + tooltip: tooltip, + child: SizedBox( + height: inputDecorationElementHeight, + width: inputDecorationElementHeight + denseSpacing, + child: IconButton( + padding: EdgeInsets.zero, + onPressed: onPressed, + iconSize: defaultIconSize, + splashRadius: defaultIconSize, + icon: Icon(icon), + ), + ), + ); + } +} diff --git a/packages/devtools_app_shared/lib/src/utils/utils.dart b/packages/devtools_app_shared/lib/src/utils/utils.dart index cd6623b0ef3..c4c21f664df 100644 --- a/packages/devtools_app_shared/lib/src/utils/utils.dart +++ b/packages/devtools_app_shared/lib/src/utils/utils.dart @@ -115,3 +115,31 @@ String toCssHexColor(Color color) { String hex(int val) => val.toRadixString(16).padLeft(2, '0'); return '#${hex(color.red)}${hex(color.green)}${hex(color.blue)}${hex(color.alpha)}'; } + +extension StringUtilities on String { + /// Whether [query] is a case insensitive "fuzzy match" for this String. + /// + /// For example, the query "hwf" would be a fuzzy match for the String + /// "hello_world_file". + bool caseInsensitiveFuzzyMatch(String query) { + query = query.toLowerCase(); + final lowercase = toLowerCase(); + final it = query.characters.iterator; + var strIndex = 0; + while (it.moveNext()) { + final char = it.current; + var foundChar = false; + for (int i = strIndex; i < lowercase.length; i++) { + if (lowercase[i] == char) { + strIndex = i + 1; + foundChar = true; + break; + } + } + if (!foundChar) { + return false; + } + } + return true; + } +} diff --git a/packages/devtools_app_shared/lib/ui.dart b/packages/devtools_app_shared/lib/ui.dart index b110f5d5583..c5f6d38b315 100644 --- a/packages/devtools_app_shared/lib/ui.dart +++ b/packages/devtools_app_shared/lib/ui.dart @@ -2,10 +2,12 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +export 'src/ui/buttons.dart'; export 'src/ui/common.dart'; export 'src/ui/dialogs.dart'; export 'src/ui/flex_split_column.dart'; export 'src/ui/split_pane.dart'; +export 'src/ui/text_field.dart'; export 'src/ui/theme/ide_theme.dart'; export 'src/ui/theme/theme.dart'; export 'src/ui/ui_utils.dart'; diff --git a/packages/devtools_app_shared/pubspec.yaml b/packages/devtools_app_shared/pubspec.yaml index 9ad4b1966b1..a3e760d02df 100644 --- a/packages/devtools_app_shared/pubspec.yaml +++ b/packages/devtools_app_shared/pubspec.yaml @@ -1,6 +1,6 @@ name: devtools_app_shared description: Package of Dart & Flutter structures shared between devtools_app and devtools extensions. -version: 0.2.1 +version: 0.2.2-wip repository: https://github.com/flutter/devtools/tree/master/packages/devtools_app_shared environment: diff --git a/packages/devtools_app_shared/test/utils/utils_test.dart b/packages/devtools_app_shared/test/utils/utils_test.dart index 0af7e76f3b9..21c3f4797e9 100644 --- a/packages/devtools_app_shared/test/utils/utils_test.dart +++ b/packages/devtools_app_shared/test/utils/utils_test.dart @@ -94,4 +94,22 @@ void main() { expect(toCssHexColor(const Color(0xFFAABBCC)), equals('#aabbccff')); }); }); + + group('StringExtension', () { + test('fuzzyMatch', () { + const str = 'hello_world_file'; + expect(str.caseInsensitiveFuzzyMatch('h'), isTrue); + expect(str.caseInsensitiveFuzzyMatch('o_'), isTrue); + expect(str.caseInsensitiveFuzzyMatch('hw'), isTrue); + expect(str.caseInsensitiveFuzzyMatch('hwf'), isTrue); + expect(str.caseInsensitiveFuzzyMatch('_e'), isTrue); + expect(str.caseInsensitiveFuzzyMatch('HWF'), isTrue); + expect(str.caseInsensitiveFuzzyMatch('_E'), isTrue); + + expect(str.caseInsensitiveFuzzyMatch('hwfh'), isFalse); + expect(str.caseInsensitiveFuzzyMatch('hfw'), isFalse); + expect(str.caseInsensitiveFuzzyMatch('gello'), isFalse); + expect(str.caseInsensitiveFuzzyMatch('files'), isFalse); + }); + }); }