mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
Keep search controls and filters visible with a dismiss button
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TextField>(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<ConcentricSheetSurface>(
|
||||
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<TextField>(find.byType(TextField)).focusNode?.hasFocus,
|
||||
isTrue,
|
||||
);
|
||||
|
||||
final focusNode = tester
|
||||
.widget<TextField>(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<FrostedAppBar>(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<void>(builder: (_) => const SearchPage())),
|
||||
child: const Text('Open search'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open search'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final appBar = tester.widget<FrostedAppBar>(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<Text>(
|
||||
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<TextField>(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<AnimatedScale>(
|
||||
find.ancestor(of: movingIcon, matching: find.byType(AnimatedScale)),
|
||||
);
|
||||
final movingField = tester.widget<AnimatedPositioned>(
|
||||
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<TextField>(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);
|
||||
|
||||
Reference in New Issue
Block a user