💄 Optimize universal search screen

This commit is contained in:
2026-02-04 01:08:16 +08:00
parent f6f1c99da7
commit 0237e457fc
2 changed files with 73 additions and 131 deletions

View File

@@ -191,6 +191,7 @@
"statusActivityEndedTitle": "{} 正在 {} {} 直到 {}", "statusActivityEndedTitle": "{} 正在 {} {} 直到 {}",
"appSettings": "应用设置", "appSettings": "应用设置",
"accountSettings": "账号设置", "accountSettings": "账号设置",
"accounts": "账号",
"settings": "设置", "settings": "设置",
"language": "语言", "language": "语言",
"accountLanguageHint": "此语言将用于电子邮件和推送通知。", "accountLanguageHint": "此语言将用于电子邮件和推送通知。",

View File

@@ -39,10 +39,27 @@ class UniversalSearchScreen extends HookConsumerWidget {
initialLength: 3, initialLength: 3,
initialIndex: initialTab.index, initialIndex: initialTab.index,
); );
final searchQuery = useState<String>('');
return AppScaffold( return AppScaffold(
isNoBackground: false, isNoBackground: false,
appBar: AppBar(title: Text('universalSearch'.tr()), elevation: 0), appBar: AppBar(
title: SearchBar(
constraints: const BoxConstraints(maxWidth: 400, minHeight: 32),
hintText: 'search'.tr(),
hintStyle: WidgetStatePropertyAll(TextStyle(fontSize: 14)),
textStyle: WidgetStatePropertyAll(TextStyle(fontSize: 14)),
onChanged: (value) {
searchQuery.value = value;
},
leading: Icon(
Symbols.search,
size: 20,
color: Theme.of(context).colorScheme.onSurface,
),
),
elevation: 0,
),
body: Column( body: Column(
children: [ children: [
TabBar( TabBar(
@@ -57,9 +74,9 @@ class UniversalSearchScreen extends HookConsumerWidget {
child: TabBarView( child: TabBarView(
controller: tabController, controller: tabController,
children: [ children: [
_PostsSearchTab(), _PostsSearchTab(searchQuery: searchQuery),
_FediverseSearchTab(), _AccountSearchTab(searchQuery: searchQuery),
_RealmsSearchTab(), _RealmsSearchTab(searchQuery: searchQuery),
], ],
), ),
), ),
@@ -70,72 +87,36 @@ class UniversalSearchScreen extends HookConsumerWidget {
} }
class _RealmsSearchTab extends HookConsumerWidget { class _RealmsSearchTab extends HookConsumerWidget {
const _RealmsSearchTab(); final ValueNotifier<String> searchQuery;
const _RealmsSearchTab({required this.searchQuery});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
Timer? debounceTimer;
final searchController = useTextEditingController();
final currentQuery = useState<String?>(null);
return Stack( return Stack(
children: [ children: [
CustomScrollView( CustomScrollView(
slivers: [ slivers: [
const SliverGap(88), const SliverGap(8),
SliverRealmList( SliverRealmList(
query: currentQuery.value, query: searchQuery.value,
key: ValueKey(currentQuery.value), key: ValueKey(searchQuery.value),
), ),
SliverGap(MediaQuery.of(context).padding.bottom + 16), SliverGap(MediaQuery.of(context).padding.bottom + 16),
], ],
), ),
Positioned(
top: 0,
left: 0,
right: 0,
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Padding(
padding: const EdgeInsets.all(16),
child: SearchBar(
elevation: WidgetStateProperty.all(4),
controller: searchController,
hintText: 'search'.tr(),
leading: const Icon(Icons.search),
padding: WidgetStateProperty.all(
const EdgeInsets.symmetric(horizontal: 24),
),
onChanged: (value) {
if (debounceTimer?.isActive ?? false) {
debounceTimer?.cancel();
}
debounceTimer = Timer(
const Duration(milliseconds: 300),
() {
if (currentQuery.value != value) {
currentQuery.value = value;
}
},
);
},
),
),
),
),
),
], ],
); );
} }
} }
class _PostsSearchTab extends HookConsumerWidget { class _PostsSearchTab extends HookConsumerWidget {
const _PostsSearchTab(); final ValueNotifier<String> searchQuery;
const _PostsSearchTab({required this.searchQuery});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final searchController = useTextEditingController();
final debounce = useMemoized(() => Duration(milliseconds: 500)); final debounce = useMemoized(() => Duration(milliseconds: 500));
final debounceTimer = useRef<Timer?>(null); final debounceTimer = useRef<Timer?>(null);
final showFilters = useState(false); final showFilters = useState(false);
@@ -151,7 +132,6 @@ class _PostsSearchTab extends HookConsumerWidget {
useEffect(() { useEffect(() {
return () { return () {
searchController.dispose();
pubNameController.dispose(); pubNameController.dispose();
realmController.dispose(); realmController.dispose();
debounceTimer.value?.cancel(); debounceTimer.value?.cancel();
@@ -188,6 +168,18 @@ class _PostsSearchTab extends HookConsumerWidget {
); );
} }
// Listen to search query changes and update the search
useEffect(() {
final query = searchQuery.value;
if (query.isNotEmpty) {
// Use Future.delayed to defer the provider modification
Future.delayed(Duration.zero, () {
onSearchChanged(query, skipDebounce: true);
});
}
return null;
}, [searchQuery.value]);
return Consumer( return Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {
final searchState = ref.watch( final searchState = ref.watch(
@@ -203,28 +195,7 @@ class _PostsSearchTab extends HookConsumerWidget {
onRefresh: noti.refresh, onRefresh: noti.refresh,
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
SliverGap(16), SliverGap(4),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
),
child: SearchBar(
elevation: WidgetStateProperty.all(4),
controller: searchController,
hintText: 'search'.tr(),
leading: const Icon(Icons.search),
padding: WidgetStateProperty.all(
const EdgeInsets.symmetric(horizontal: 24),
),
onChanged: onSearchChanged,
onSubmitted: (value) {
onSearchChanged(value, skipDebounce: true);
},
),
),
),
const SliverGap(12),
PaginationList( PaginationList(
provider: postListProvider( provider: postListProvider(
PostListQueryConfig(id: kSearchPostListId), PostListQueryConfig(id: kSearchPostListId),
@@ -256,14 +227,14 @@ class _PostsSearchTab extends HookConsumerWidget {
}, },
), ),
if (searchState.value?.items.isEmpty == true && if (searchState.value?.items.isEmpty == true &&
searchController.text.isNotEmpty && searchQuery.value.isNotEmpty &&
!searchState.isLoading) !searchState.isLoading)
SliverFillRemaining( SliverFillRemaining(
child: Center(child: Text('noResultsFound'.tr())), child: Center(child: Text('noResultsFound'.tr())),
), ),
SliverGap(MediaQuery.of(context).padding.bottom + 16), SliverGap(MediaQuery.of(context).padding.bottom + 16),
], ],
).padding(left: 8), ).padding(left: 16),
), ),
), ),
Flexible( Flexible(
@@ -271,10 +242,11 @@ class _PostsSearchTab extends HookConsumerWidget {
child: Align( child: Align(
alignment: Alignment.topLeft, alignment: Alignment.topLeft,
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.only(right: 12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Gap(16), Gap(8),
Card( Card(
margin: EdgeInsets.symmetric(horizontal: 8), margin: EdgeInsets.symmetric(horizontal: 8),
child: Padding( child: Padding(
@@ -319,28 +291,6 @@ class _PostsSearchTab extends HookConsumerWidget {
) )
: CustomScrollView( : CustomScrollView(
slivers: [ slivers: [
const SliverGap(4),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: SearchBar(
elevation: WidgetStateProperty.all(4),
controller: searchController,
hintText: 'search'.tr(),
leading: const Icon(Icons.search),
padding: WidgetStateProperty.all(
const EdgeInsets.symmetric(horizontal: 24),
),
onChanged: onSearchChanged,
onSubmitted: (value) {
onSearchChanged(value, skipDebounce: true);
},
),
),
),
const SliverGap(8), const SliverGap(8),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Column( child: Column(
@@ -396,25 +346,17 @@ class _PostsSearchTab extends HookConsumerWidget {
child: const PostItemSkeleton(maxWidth: double.infinity), child: const PostItemSkeleton(maxWidth: double.infinity),
), ),
itemBuilder: (context, index, post) { itemBuilder: (context, index, post) {
return Center( return Card(
child: ConstrainedBox( margin: EdgeInsets.symmetric(
constraints: BoxConstraints(maxWidth: 600), horizontal: 8,
child: Card( vertical: 4,
margin: EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: PostActionableItem(
item: post,
borderRadius: 8,
),
),
), ),
child: PostActionableItem(item: post, borderRadius: 8),
); );
}, },
), ),
if (searchState.value?.items.isEmpty == true && if (searchState.value?.items.isEmpty == true &&
searchController.text.isNotEmpty && searchQuery.value.isNotEmpty &&
!searchState.isLoading) !searchState.isLoading)
SliverFillRemaining( SliverFillRemaining(
child: Center(child: Text('noResultsFound'.tr())), child: Center(child: Text('noResultsFound'.tr())),
@@ -426,12 +368,13 @@ class _PostsSearchTab extends HookConsumerWidget {
} }
} }
class _FediverseSearchTab extends HookConsumerWidget { class _AccountSearchTab extends HookConsumerWidget {
const _FediverseSearchTab(); final ValueNotifier<String> searchQuery;
const _AccountSearchTab({required this.searchQuery});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final searchController = useTextEditingController();
final debounce = useMemoized(() => const Duration(milliseconds: 500)); final debounce = useMemoized(() => const Duration(milliseconds: 500));
final debounceTimer = useRef<Timer?>(null); final debounceTimer = useRef<Timer?>(null);
final fediverseResults = useState<List<SnActivityPubActor>>([]); final fediverseResults = useState<List<SnActivityPubActor>>([]);
@@ -440,7 +383,6 @@ class _FediverseSearchTab extends HookConsumerWidget {
useEffect(() { useEffect(() {
return () { return () {
searchController.dispose();
debounceTimer.value?.cancel(); debounceTimer.value?.cancel();
}; };
}, []); }, []);
@@ -531,6 +473,18 @@ class _FediverseSearchTab extends HookConsumerWidget {
} }
} }
// Listen to search query changes and update the search
useEffect(() {
final query = searchQuery.value;
if (query.isNotEmpty) {
// Use Future.delayed to defer the provider modification
Future.delayed(Duration.zero, () {
onSearchChanged(query);
});
}
return null;
}, [searchQuery.value]);
// Combine and display results - local users first // Combine and display results - local users first
final allResults = [ final allResults = [
...internalResults.value.map( ...internalResults.value.map(
@@ -543,19 +497,6 @@ class _FediverseSearchTab extends HookConsumerWidget {
return Column( return Column(
children: [ children: [
Padding(
padding: const EdgeInsets.all(16),
child: SearchBar(
controller: searchController,
hintText: 'searchAccountsHint'.tr(),
leading: const Icon(Symbols.search).padding(horizontal: 24),
onChanged: onSearchChanged,
onSubmitted: (value) {
onSearchChanged(value);
performSearch(value);
},
),
),
Expanded( Expanded(
child: isSearching.value child: isSearching.value
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
@@ -569,8 +510,8 @@ class _FediverseSearchTab extends HookConsumerWidget {
size: 64, size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
const SizedBox(height: 16), const Gap(16),
if (searchController.text.isEmpty) if (searchQuery.value.isEmpty)
Text( Text(
'searchUsersEmpty'.tr(), 'searchUsersEmpty'.tr(),
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
@@ -584,7 +525,7 @@ class _FediverseSearchTab extends HookConsumerWidget {
), ),
) )
: ExtendedRefreshIndicator( : ExtendedRefreshIndicator(
onRefresh: () => performSearch(searchController.text), onRefresh: () => performSearch(searchQuery.value),
child: ListView.separated( child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: allResults.length, itemCount: allResults.length,