From 8ba536060d2baca2ab78d788951dced3a33d1802 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Thu, 15 Oct 2026 08:21:53 -0400 Subject: [PATCH] Keep search controls and filters visible with a dismiss button Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Signed-off-by: kenny lopez --- mobile/lib/features/search/search_page.dart | 359 +++++-------- .../features/search/search_page_test.dart | 486 +++--------------- 2 files changed, 193 insertions(+), 652 deletions(-) diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 8152d94392..442f7680f4 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -12,8 +12,8 @@ import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/buzz_search_field.dart'; import '../../shared/widgets/filter_chip_bar.dart'; -import '../../shared/widgets/frosted_app_bar.dart'; -import '../../shared/widgets/frosted_scaffold.dart'; +import '../../shared/widgets/concentric_sheet_surface.dart'; +import '../../shared/widgets/ios_glass_navigation_button.dart'; import '../../shared/widgets/message_author_meta.dart'; import '../channels/channel.dart'; import '../channels/channel_detail_page.dart'; @@ -34,34 +34,12 @@ part 'search_page/message_tile.dart'; enum _SearchFilter { all, messages, channels, people } -const _searchIdleFieldHeight = 45.0; +const _searchIdleFieldHeight = 48.0; const _searchIdleTextSize = 15.0; const _searchFieldVerticalPadding = Grid.xxs; -const _searchFieldMoveDuration = Duration(milliseconds: 160); -const _searchCancelEnterDuration = Duration(milliseconds: 80); -const _searchIdleFieldTopInset = Grid.half; -const _searchControlsToFiltersGap = Grid.xxs; const _searchFilterChipVerticalPadding = Grid.xxs; const _searchFilterBarVerticalPadding = Grid.xxs; const _searchHeaderFiltersMinHeight = Grid.xl; -const _searchActiveFieldRightInsetMin = 72.0; - -/// Reserves the Cancel action's scaled label, padding, and app-bar edge inset. -double _searchActiveFieldRightInset(BuildContext context) { - final textPainter = TextPainter( - text: TextSpan( - text: 'Cancel', - style: filterChipTextStyle.copyWith(fontWeight: FontWeight.w500), - ), - textScaler: MediaQuery.textScalerOf(context), - textDirection: Directionality.of(context), - )..layout(); - final cancelWidth = - textPainter.width + Grid.half * 2 + Grid.gutter + Grid.xxs; - return cancelWidth > _searchActiveFieldRightInsetMin - ? cancelWidth - : _searchActiveFieldRightInsetMin; -} double _idleSearchFieldHeight(BuildContext context) { final scaledFontSize = MediaQuery.textScalerOf( @@ -101,80 +79,36 @@ class SearchPage extends HookConsumerWidget { .whenData((value) => value?.pubkey) .value; final activeFilter = useState(_SearchFilter.all); - final textController = useTextEditingController(); + final textController = useTextEditingController(text: searchState.query); final focusNode = useFocusNode(); - final isSearchEditing = useState(false); - final isTabActivationInFlight = useRef(false); - final reduceMotion = MediaQuery.disableAnimationsOf(context); + final nativeIos = defaultTargetPlatform == TargetPlatform.iOS; final searchSurfaceColor = navigationSearchSurface(context); final searchPrimaryColor = navigationPrimaryForeground(context); final searchPlaceholderColor = navigationSecondaryForeground(context); - final headerTitleStyle = context.textTheme.titleMedium?.copyWith( - fontSize: 22, - fontWeight: FontWeight.w600, - ); - final idleSearchFieldHeight = _idleSearchFieldHeight(context); - final searchHeaderFiltersHeight = _searchHeaderFiltersHeight(context); - final searchActiveFieldRightInset = _searchActiveFieldRightInset(context); - final nativeIos = defaultTargetPlatform == TargetPlatform.iOS; - const activeFieldTop = _searchIdleFieldTopInset; - final filtersTop = - activeFieldTop + idleSearchFieldHeight + _searchControlsToFiltersGap; - final searchControlHeight = idleSearchFieldHeight > Grid.xl - ? idleSearchFieldHeight - : Grid.xl; - final searchHeaderBottomHeight = isSearchEditing.value - ? filtersTop + searchHeaderFiltersHeight + _searchControlsToFiltersGap - : idleSearchFieldHeight + _searchIdleFieldTopInset + Grid.xxs; - final topSectionHeight = frostedAppBarHeight( - context, - titleStyle: headerTitleStyle, - bottomHeight: searchHeaderBottomHeight, - ); + final fieldHeight = _idleSearchFieldHeight(context); + final filtersHeight = _searchHeaderFiltersHeight(context); - void activateSearch() { - isSearchEditing.value = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted && isSearchEditing.value) { - focusNode.requestFocus(); - } - }); - } - - void deactivateSearch() { - if (!isSearchEditing.value) return; - isSearchEditing.value = false; - } + void activateSearch() => focusNode.requestFocus(); useEffect(() { - final tabReselection = this.tabReselection; - if (tabReselection == null) return null; - + final reselection = tabReselection; + if (reselection == null) return null; void reactivateSearch() { - // The same tab gesture can report focus loss after this callback. Keep - // that notification from starting a competing return animation while - // the normal field activation path restores focus. - isTabActivationInFlight.value = true; - activateSearch(); + focusNode.requestFocus(); WidgetsBinding.instance.addPostFrameCallback((_) { - isTabActivationInFlight.value = false; + if (context.mounted) focusNode.requestFocus(); }); + WidgetsBinding.instance.ensureVisualUpdate(); } - tabReselection.addListener(reactivateSearch); - return () => tabReselection.removeListener(reactivateSearch); + reselection.addListener(reactivateSearch); + return () => reselection.removeListener(reactivateSearch); }, [tabReselection, focusNode]); - useEffect(() { - void resetIdlePromptWhenFocusLeaves() { - if (!focusNode.hasFocus && !isTabActivationInFlight.value) { - deactivateSearch(); - } - } - - focusNode.addListener(resetIdlePromptWhenFocusLeaves); - return () => focusNode.removeListener(resetIdlePromptWhenFocusLeaves); - }, [focusNode]); + void closeSearch() { + focusNode.unfocus(); + Navigator.of(context).maybePop(); + } void runRecentSearch(String query) { textController.value = TextEditingValue( @@ -186,179 +120,118 @@ class SearchPage extends HookConsumerWidget { ref.read(searchProvider.notifier).search(query); } - return FrostedScaffold( - nativePinnedBody: true, + return Scaffold( backgroundColor: context.colors.surface, - // Keep the empty state centered in the page rather than the portion left - // above the keyboard. resizeToAvoidBottomInset: false, - appBar: FrostedAppBar( - nativeTitle: 'Search', - nativeLayoutDuration: Duration.zero, - nativeLargeTitle: true, - nativeActions: const [], - automaticallyImplyLeading: true, - horizontalInset: Grid.twelve, - showBottomDivider: true, - bottomDividerOpacity: 0.07, - titleStyle: headerTitleStyle, - title: Text( - 'Search', - key: const ValueKey('search-header-title'), - style: headerTitleStyle, - ), - actions: const [], - bottomHeight: searchHeaderBottomHeight, - bottomOverlap: 0, - bottom: Stack( - clipBehavior: Clip.none, + body: SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - AnimatedPositioned( - duration: Duration.zero, - curve: Curves.easeInOutCubic, - left: Grid.gutter, - right: isSearchEditing.value - ? searchActiveFieldRightInset - : Grid.gutter, - top: activeFieldTop, - height: idleSearchFieldHeight, - // Do not key this subtree by the visual state: replacing the - // TextField immediately after its first tap can detach its - // native input connection before the keyboard is shown. - child: SizedBox( - key: const Key('search-field-container'), - child: BuzzSearchField( - controller: textController, - focusNode: focusNode, - hintText: 'Search messages, channels, and people', - iconColor: searchPrimaryColor, - inputColor: searchPrimaryColor, - placeholderColor: searchPlaceholderColor, - surfaceColor: searchSurfaceColor, - isEditing: isSearchEditing.value, - reduceMotion: reduceMotion, - motionDuration: _searchFieldMoveDuration, - onTap: activateSearch, - onChanged: (value) => - ref.read(searchProvider.notifier).search(value), - onSubmitted: (value) { - final query = value.trim(); - if (query.isEmpty) return; - ref.read(recentSearchesProvider.notifier).record(query); - }, - ), + Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + Grid.half, + Grid.gutter, + Grid.xxs, ), - ), - if (isSearchEditing.value) - Positioned( - top: activeFieldTop, - right: Grid.gutter, - width: searchActiveFieldRightInset - Grid.gutter - Grid.xxs, - height: searchControlHeight, - child: Semantics( - button: true, - label: 'Cancel search', - child: GestureDetector( - key: Key(nativeIos ? 'search-ios-cancel' : 'search-cancel'), - behavior: HitTestBehavior.opaque, - onTap: () { - textController.clear(); - ref.read(searchProvider.notifier).clear(); - deactivateSearch(); - focusNode.unfocus(); - }, - child: Align( - alignment: Alignment.topRight, - child: SizedBox( - height: idleSearchFieldHeight, - child: Align( - alignment: Alignment.centerRight, - child: Text( - 'Cancel', - style: filterChipTextStyle.copyWith( - color: context.colors.primary, - fontWeight: FontWeight.w500, - ), - ), + child: Row( + children: [ + Expanded( + child: SizedBox( + key: const Key('search-field-container'), + height: fieldHeight, + child: ConcentricSheetSurface( + enabled: nativeIos, + usesGlass: true, + providesSheetSurface: false, + padding: EdgeInsets.zero, + color: searchSurfaceColor, + minimumRadius: fieldHeight / 2, + contentClipRadius: fieldHeight / 2, + child: BuzzSearchField( + controller: textController, + focusNode: focusNode, + hintText: 'Search messages, channels, and people', + iconColor: searchPrimaryColor, + inputColor: searchPrimaryColor, + placeholderColor: searchPlaceholderColor, + surfaceColor: nativeIos + ? Colors.transparent + : searchSurfaceColor, + isEditing: false, + reduceMotion: true, + motionDuration: Duration.zero, + onTap: activateSearch, + onChanged: (value) => + ref.read(searchProvider.notifier).search(value), + onSubmitted: (value) { + final query = value.trim(); + if (query.isNotEmpty) { + ref + .read(recentSearchesProvider.notifier) + .record(query); + } + }, ), ), ), ), - ), - ), - Positioned.fill( - child: AnimatedSwitcher( - duration: reduceMotion - ? Duration.zero - : _searchCancelEnterDuration, - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - transitionBuilder: (child, animation) => - FadeTransition(opacity: animation, child: child), - child: isSearchEditing.value - ? Align( - alignment: Alignment.topCenter, - child: Padding( - padding: EdgeInsets.only(top: filtersTop), - child: SizedBox( - key: const ValueKey('search-header-filters'), - height: searchHeaderFiltersHeight, - child: FilterChipBar<_SearchFilter>( - expandItems: true, - visualDensity: const VisualDensity( - horizontal: -2, - ), - chipVerticalPadding: - _searchFilterChipVerticalPadding, - barVerticalPadding: - _searchFilterBarVerticalPadding, - selected: activeFilter.value, - onSelected: (f) => activeFilter.value = f, - items: [ - for (final f in _SearchFilter.values) - FilterChipItem(id: f, label: f.label), - ], - ), - ), + const SizedBox(width: Grid.xxs), + if (nativeIos) + IosGlassNavigationButton( + key: const Key('search-close'), + icon: IosGlassNavigationIcon.close, + semanticLabel: 'Close search', + onPressed: closeSearch, + controlSize: 48, + foregroundColor: searchPrimaryColor, + ) + else + SizedBox.square( + key: const Key('search-close'), + dimension: 48, + child: IconButton( + tooltip: 'Close search', + onPressed: closeSearch, + style: IconButton.styleFrom( + backgroundColor: searchSurfaceColor, + foregroundColor: searchPrimaryColor, ), - ) - : const SizedBox.shrink( - key: ValueKey('search-header-filters-hidden'), + icon: const Icon(BuzzIcons.x), ), + ), + ], + ), + ), + SizedBox( + key: const ValueKey('search-header-filters'), + height: filtersHeight, + child: FilterChipBar<_SearchFilter>( + expandItems: true, + visualDensity: const VisualDensity(horizontal: -2), + chipVerticalPadding: _searchFilterChipVerticalPadding, + barVerticalPadding: _searchFilterBarVerticalPadding, + selected: activeFilter.value, + onSelected: (filter) => activeFilter.value = filter, + items: [ + for (final filter in _SearchFilter.values) + FilterChipItem(id: filter, label: filter.label), + ], + ), + ), + const SizedBox(height: Grid.xxs), + Expanded( + child: _SearchBody( + state: searchState, + filter: activeFilter.value, + currentPubkey: currentPubkey, + onRecentSearchSelected: runRecentSearch, ), ), ], ), ), - body: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox(height: topSectionHeight), - Expanded( - child: ClipRRect( - borderRadius: const BorderRadius.vertical( - top: Radius.circular(Radii.dialog), - ), - child: Material( - color: context.colors.surface, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: _SearchBody( - state: searchState, - filter: activeFilter.value, - currentPubkey: currentPubkey, - onRecentSearchSelected: runRecentSearch, - ), - ), - ], - ), - ), - ), - ), - ], - ), ); } } diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart index ce5eea22e7..55abc3d113 100644 --- a/mobile/test/features/search/search_page_test.dart +++ b/mobile/test/features/search/search_page_test.dart @@ -11,7 +11,8 @@ import 'package:buzz/features/search/search_page.dart'; import 'package:buzz/features/search/search_provider.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/mentions/agent_identity_provider.dart'; -import 'package:buzz/shared/widgets/frosted_app_bar.dart'; +import 'package:buzz/shared/widgets/concentric_sheet_surface.dart'; +import 'package:buzz/shared/widgets/ios_glass_navigation_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -20,12 +21,16 @@ import 'package:buzz/shared/theme/buzz_icons.dart'; import '../../helpers/widget_helpers.dart'; void main() { - for (final reducedMotion in [false, true]) { - testWidgets( - 'iOS search field stays in place above tappable chips, reduced motion=$reducedMotion', - (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) { + for (final scale in [1.0, 2.0]) { + testWidgets('search controls stay fixed on $platform at scale $scale', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = platform; addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); await tester.pumpWidget( WidgetHelpers.testable( overrides: [ @@ -39,8 +44,7 @@ void main() { ], child: MediaQuery( data: MediaQueryData( - disableAnimations: reducedMotion, - textScaler: TextScaler.linear(reducedMotion ? 2 : 1), + textScaler: TextScaler.linear(scale), padding: const EdgeInsets.only(top: 59), ), child: const SearchPage(), @@ -49,198 +53,55 @@ void main() { ); await tester.pumpAndSettle(); final field = find.byKey(const Key('search-field-container')); - final idle = tester.getRect(field); - final input = tester.element(find.byType(TextField)); - await tester.tap(find.byKey(const Key('search-field'))); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 80)); - final middle = tester.getRect(field); - await tester.pumpAndSettle(); - final active = tester.getRect(field); - final cancel = find.byKey(const Key('search-ios-cancel')); - final cancelRect = tester.getRect(cancel); - expect(cancel.hitTestable(), findsOneWidget); - expect(cancelRect.left - active.right, closeTo(Grid.xxs, 0.01)); - expect( - tester.getRect(find.byType(FrostedAppBar)).right - cancelRect.right, - closeTo(Grid.gutter, 0.01), - ); - expect(active.top, closeTo(idle.top, 0.01)); - expect(active.height, closeTo(idle.height, 0.01)); - expect(active.width, lessThan(idle.width)); - expect(active.top, greaterThanOrEqualTo(59)); - if (!reducedMotion) { - expect(middle.top, closeTo(idle.top, 0.01)); + final filters = find.byKey(const Key('search-header-filters')); + final close = find.byKey(const Key('search-close')); + final fieldRect = tester.getRect(field); + final filtersRect = tester.getRect(filters); + expect(find.text('Search'), findsNothing); + expect(find.text('Cancel'), findsNothing); + expect(fieldRect.top, greaterThanOrEqualTo(59)); + expect(tester.getRect(close).left, greaterThan(fieldRect.right)); + expect(tester.getSize(close), const Size(48, 48)); + for (final label in ['All', 'Messages', 'Channels', 'People']) { + expect(find.text(label).hitTestable(), findsOneWidget); } + final input = tester.element(find.byType(TextField)); + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + expect(tester.getRect(field), fieldRect); + expect(tester.getRect(filters), filtersRect); expect(tester.element(find.byType(TextField)), same(input)); expect( tester.widget(find.byType(TextField)).focusNode!.hasFocus, isTrue, ); - final filters = tester.getRect( - find.byKey(const Key('search-header-filters')), - ); - expect(filters.top - active.bottom, closeTo(Grid.xxs, 0.01)); - expect( - tester.getRect(find.byType(FrostedAppBar)).bottom, - greaterThanOrEqualTo(filters.bottom), - ); - for (final label in ['All', 'Messages', 'Channels', 'People']) { - expect(find.text(label).hitTestable(), findsOneWidget); - } await tester.enterText(find.byType(TextField), 'design'); - await tester.tap(cancel); + await tester.tap(find.text('Channels')); await tester.pumpAndSettle(); - expect(tester.getRect(field).top, closeTo(idle.top, 0.01)); - await tester.pumpWidget(const SizedBox()); + expect(tester.getRect(field), fieldRect); + expect(tester.getRect(filters), filtersRect); + expect( + tester + .widget( + find.byType(ConcentricSheetSurface), + ) + .usesGlass, + isTrue, + ); + expect( + find.byType(IosGlassNavigationButton), + platform == TargetPlatform.iOS ? findsOneWidget : findsNothing, + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); debugDefaultTargetPlatformOverride = null; - }, - ); + }); + } } - testWidgets('iOS recent rows move once through title collapse', ( + testWidgets('X dismisses search while the keyboard is focused', ( tester, ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [ - searchProvider.overrideWith( - () => _FakeSearchNotifier(const SearchState.initial()), - ), - recentSearchesProvider.overrideWith( - () => _FakeRecentSearchesNotifier( - List.generate(30, (i) => 'Recent query $i'), - ), - ), - profileProvider.overrideWith(() => _FakeProfileNotifier()), - ], - child: const SearchPage(), - ), - ); - await tester.pumpAndSettle(); - final row = find.text('Recent query 1'); - final list = find.byKey(const Key('recent-searches-list')); - final gesture = await tester.startGesture(tester.getCenter(list)); - await gesture.moveBy(const Offset(0, -20)); - await tester.pump(); - for (var i = 0; i < 5; i++) { - final before = tester.getTopLeft(row).dy; - await gesture.moveBy(const Offset(0, -20)); - await tester.pump(); - expect(before - tester.getTopLeft(row).dy, closeTo(20, 1)); - } - await gesture.up(); - await tester.pumpWidget(const SizedBox()); - debugDefaultTargetPlatformOverride = null; - }); - - testWidgets('reselecting Search uses the field activation path', ( - tester, - ) async { - final tabReselection = ValueNotifier(0); - addTearDown(tabReselection.dispose); - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [ - searchProvider.overrideWith( - () => _FakeSearchNotifier(const SearchState.initial()), - ), - recentSearchesProvider.overrideWith( - () => _FakeRecentSearchesNotifier(const []), - ), - profileProvider.overrideWith(() => _FakeProfileNotifier()), - ], - child: SearchPage(tabReselection: tabReselection), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('search-cancel')), findsNothing); - tabReselection.value++; - await tester.pump(); - await tester.pump(); - - expect(find.byKey(const Key('search-cancel')), findsOneWidget); - expect( - tester.widget(find.byType(TextField)).focusNode?.hasFocus, - isTrue, - ); - - final focusNode = tester - .widget(find.byType(TextField)) - .focusNode!; - // Reproduce the real tab-tap ordering where the destination callback can - // run immediately before the same pointer gesture dismisses the field. - tabReselection.value++; - focusNode.unfocus(); - await tester.pump(); - await tester.pump(); - - expect(find.byKey(const Key('search-cancel')), findsOneWidget); - expect(focusNode.hasFocus, isTrue); - expect(find.byKey(const Key('search-header-title')), findsOneWidget); - }); - - testWidgets('uses the shared frosted navigation surface', (tester) async { - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [ - searchProvider.overrideWith( - () => _FakeSearchNotifier(const SearchState.initial()), - ), - recentSearchesProvider.overrideWith( - () => _FakeRecentSearchesNotifier(const []), - ), - profileProvider.overrideWith(() => _FakeProfileNotifier()), - ], - child: const SearchPage(), - ), - ); - await tester.pumpAndSettle(); - - final appBar = tester.widget(find.byType(FrostedAppBar)); - expect(appBar.gradient, isNull); - expect(appBar.frosted, isTrue); - expect(appBar.showBottomDivider, isTrue); - expect(appBar.bottomDividerOpacity, 0.07); - expect(appBar.bottomHeight, 57); - expect(appBar.leading, isNull); - expect(appBar.centerTitle, isFalse); - expect(find.text('Search'), findsOneWidget); - final promptText = find.descendant( - of: find.byKey(const Key('search-field-container')), - matching: find.byType(Text), - ); - expect( - tester.getRect(promptText).left, - closeTo( - tester.getRect(find.byKey(const Key('search-moving-icon'))).right + - Grid.xxs, - 0.01, - ), - ); - expect( - tester.getRect(promptText).center.dy, - closeTo( - tester - .getRect(find.byKey(const Key('search-field-container'))) - .center - .dy, - 0.5, - ), - ); - }); - - testWidgets('empty state preserves large accessible text scaling', ( - tester, - ) async { - tester.view.physicalSize = const Size(320, 640); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - await tester.pumpWidget( WidgetHelpers.testable( overrides: [ @@ -253,124 +114,31 @@ void main() { profileProvider.overrideWith(() => _FakeProfileNotifier()), ], child: Builder( - builder: (context) => MediaQuery( - data: MediaQuery.of( + builder: (context) => TextButton( + onPressed: () => Navigator.of( context, - ).copyWith(textScaler: const TextScaler.linear(2)), - child: const SearchPage(), + ).push(MaterialPageRoute(builder: (_) => const SearchPage())), + child: const Text('Open search'), ), ), ), ); + await tester.tap(find.text('Open search')); await tester.pumpAndSettle(); - - final appBar = tester.widget(find.byType(FrostedAppBar)); - final titleStyle = appBar.titleStyle!; - expect(titleStyle.fontSize, 22); - expect( - tester - .getSize( - find - .descendant( - of: find.byType(FrostedAppBar), - matching: find.byType(ClipRect), - ) - .first, - ) - .height, - closeTo( - frostedAppBarHeight( - tester.element(find.byType(FrostedAppBar)), - titleStyle: titleStyle, - bottomHeight: appBar.bottomHeight, - ), - 0.01, - ), - ); - final emptyState = find.byKey(const Key('search-empty-state')); - final searchField = find.byKey(const Key('search-field-container')); - final searchFieldContext = tester.element(searchField); - final bodyStyle = Theme.of(searchFieldContext).textTheme.bodyMedium!; - final scaledLineHeight = - MediaQuery.textScalerOf(searchFieldContext).scale(bodyStyle.fontSize!) * - bodyStyle.height!; - - expect( - find.descendant(of: emptyState, matching: find.byType(FittedBox)), - findsNothing, - ); - expect( - tester.getSize(searchField).height, - greaterThanOrEqualTo(scaledLineHeight + Grid.xxs * 2), - ); - final prompt = tester.widget( - find.descendant( - of: find.byKey(const Key('search-field-container')), - matching: find.byType(Text), - ), - ); - expect(prompt.style?.fontSize, 15); - expect(prompt.maxLines, 1); - expect(prompt.overflow, TextOverflow.ellipsis); - expect( - tester - .getSize(find.descendant(of: emptyState, matching: find.byType(Text))) - .height, - greaterThan(32), - ); - expect(tester.takeException(), isNull); + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'draft'); + await tester.tap(find.byTooltip('Close search')); + await tester.pumpAndSettle(); + expect(find.byType(SearchPage), findsNothing); + expect(find.text('Open search'), findsOneWidget); }); - testWidgets('search filters grow with accessible text', (tester) async { - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [ - searchProvider.overrideWith( - () => _FakeSearchNotifier(const SearchState.initial()), - ), - recentSearchesProvider.overrideWith( - () => _FakeRecentSearchesNotifier(const []), - ), - profileProvider.overrideWith(() => _FakeProfileNotifier()), - ], - child: Builder( - builder: (context) => MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(textScaler: const TextScaler.linear(2)), - child: const SearchPage(), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byKey(const Key('search-field'))); - await tester.pumpAndSettle(); - - final filters = find.byKey(const Key('search-header-filters')); - final activeField = find.byKey(const Key('search-field-container')); - final cancel = find.byKey(const Key('search-cancel')); - expect(filters, findsOneWidget); - expect(cancel, findsOneWidget); - expect( - tester.getRect(activeField).right, - lessThanOrEqualTo(tester.getRect(cancel).left), - reason: 'Scaled Cancel must not overlap the active search field.', - ); - expect(tester.getSize(filters).height, greaterThan(Grid.xl)); - expect( - tester.getSize(filters).height, - greaterThanOrEqualTo( - tester.getSize(find.text('Messages')).height + Grid.xs * 2, - ), - ); - expect(tester.takeException(), isNull); - }); - - testWidgets('focus shows Cancel and chips without moving the search field', ( + testWidgets('reselecting Search restores input focus without moving tabs', ( tester, ) async { + final reselection = ValueNotifier(0); + addTearDown(reselection.dispose); await tester.pumpWidget( WidgetHelpers.testable( overrides: [ @@ -382,124 +150,24 @@ void main() { ), profileProvider.overrideWith(() => _FakeProfileNotifier()), ], - child: const SearchPage(), + child: SearchPage(tabReselection: reselection), ), ); await tester.pumpAndSettle(); - - final searchField = find.byKey(const Key('search-field')); - final editingField = find.byType(TextField); - final searchFieldContainer = find.byKey( - const Key('search-field-container'), - ); - final unfocusedWidth = tester.getSize(searchFieldContainer).width; - final unfocusedTop = tester.getRect(searchFieldContainer).top; - expect(find.byKey(const Key('search-cancel')), findsNothing); - await tester.tap(searchField); - await tester.pump(); - - final cancel = find.byKey(const Key('search-cancel')); - expect(cancel, findsOneWidget); - expect( - tester.getSize(cancel).height, - greaterThanOrEqualTo(Grid.xl), - reason: 'Cancel must keep a 48dp touch target.', - ); - final input = tester.widget(editingField); - expect(input.decoration?.hintText, isNull); - expect(input.textInputAction, TextInputAction.search); - expect( - input.focusNode?.hasFocus, - isTrue, - reason: 'Tapping the idle search field opens the native keyboard.', - ); - await tester.pump(const Duration(milliseconds: 160)); - final focusedWidth = tester.getSize(searchFieldContainer).width; - final focusedRect = tester.getRect(searchFieldContainer); - expect(focusedWidth, lessThan(unfocusedWidth)); - expect( - focusedRect.top, - closeTo(unfocusedTop, 0.01), - reason: 'The active field stays below the header.', - ); - expect(find.byKey(const Key('search-header-filters')), findsOneWidget); - final filtersRect = tester.getRect( + final filters = tester.getRect( find.byKey(const Key('search-header-filters')), ); - expect( - filtersRect.top - focusedRect.bottom, - Grid.xxs, - reason: 'Filters keep one compact spacing token below the controls.', - ); - final movingIcon = find.byKey(const Key('search-moving-icon')); - final iconScale = tester.widget( - find.ancestor(of: movingIcon, matching: find.byType(AnimatedScale)), - ); - final movingField = tester.widget( - find.ancestor(of: movingIcon, matching: find.byType(AnimatedPositioned)), - ); - expect(iconScale.scale, lessThan(1)); - expect(movingField.top, Grid.half); - final appBarRect = tester.getRect(find.byType(FrostedAppBar)); - expect( - appBarRect.bottom - filtersRect.bottom, - closeTo(Grid.xxs + 1, 0.01), - reason: - 'The filter row keeps the same spacing below it, plus the divider.', - ); - expect( - appBarRect.contains(focusedRect.center), - isTrue, - reason: 'The field remains inside the app bar hit-test box.', - ); - - await tester.enterText(editingField, 'design'); - await tester.tap(cancel); - await tester.pump(); - - expect(find.byKey(const Key('search-header-title')), findsOneWidget); + reselection.value++; await tester.pumpAndSettle(); - - expect(find.byKey(const Key('search-cancel')), findsNothing); - expect( - tester.getSize(searchFieldContainer).width, - closeTo(unfocusedWidth, 0.01), - ); - }); - - testWidgets('keeps the search prompt calm until it is focused', ( - tester, - ) async { - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [ - searchProvider.overrideWith( - () => _FakeSearchNotifier(const SearchState.initial()), - ), - recentSearchesProvider.overrideWith( - () => _FakeRecentSearchesNotifier(const []), - ), - profileProvider.overrideWith(() => _FakeProfileNotifier()), - ], - child: const SearchPage(), - ), - ); + final focus = tester.widget(find.byType(TextField)).focusNode!; + expect(focus.hasFocus, isTrue); + reselection.value++; + focus.unfocus(); await tester.pumpAndSettle(); - - final searchField = find.byKey(const Key('search-field')); - final searchFieldContainer = find.byKey( - const Key('search-field-container'), - ); - expect(find.text('Messages'), findsNothing); - expect(tester.getSize(searchFieldContainer).height, greaterThan(36)); - - await tester.tap(searchField); - await tester.pumpAndSettle(); - - expect(find.text('Messages'), findsOneWidget); + expect(focus.hasFocus, isTrue); expect( - tester.getSize(searchFieldContainer).height, - greaterThanOrEqualTo(36), + tester.getRect(find.byKey(const Key('search-header-filters'))), + filters, ); }); @@ -526,7 +194,7 @@ void main() { await tester.tap(searchField); await tester.pumpAndSettle(); await tester.enterText(searchField, 'draft'); - await tester.tap(find.byKey(const Key('search-cancel'))); + await tester.enterText(find.byKey(const Key('search-field')), ''); await tester.pumpAndSettle(); expect(find.byKey(const Key('recent-searches-list')), findsNothing); @@ -535,11 +203,11 @@ void main() { await tester.enterText(searchField, 'design systems'); await tester.testTextInput.receiveAction(TextInputAction.search); await tester.pumpAndSettle(); - expect(find.byKey(const Key('search-cancel')), findsNothing); + expect(find.byKey(const Key('search-close')), findsOneWidget); // Submission dismisses editing immediately. Reopen before clearing. await tester.tap(searchField); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('search-cancel'))); + await tester.enterText(find.byKey(const Key('search-field')), ''); await tester.pumpAndSettle(); expect(find.byKey(const Key('recent-searches-list')), findsOneWidget); @@ -588,7 +256,7 @@ void main() { expect(input.focusNode?.hasFocus, isTrue); expect(find.text("No results for 'launch plan'"), findsOneWidget); - await tester.tap(find.byKey(const Key('search-cancel'))); + await tester.enterText(find.byKey(const Key('search-field')), ''); await tester.pumpAndSettle(); expect(find.byKey(const Key('recent-search-0')), findsOneWidget); expect(find.text('launch plan'), findsOneWidget);