Files
App/lib/screens/search.dart
2026-01-02 03:08:50 +08:00

485 lines
18 KiB
Dart

import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:gap/gap.dart';
import 'package:island/models/activitypub.dart';
import 'package:island/pods/post/post_list.dart';
import 'package:island/services/activitypub_service.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/activitypub/actor_list_item.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/extended_refresh_indicator.dart';
import 'package:island/widgets/paging/pagination_list.dart';
import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/post_item_skeleton.dart';
import 'package:island/widgets/posts/post_filter.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
const kSearchPostListId = 'search';
enum SearchTab { posts, fediverse }
class UniversalSearchScreen extends HookConsumerWidget {
final SearchTab initialTab;
const UniversalSearchScreen({super.key, this.initialTab = SearchTab.posts});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabController = useTabController(
initialLength: 2,
initialIndex: initialTab.index,
);
return AppScaffold(
isNoBackground: false,
appBar: AppBar(title: Text('search'.tr()), elevation: 0),
body: Column(
children: [
TabBar(
controller: tabController,
tabs: [
Tab(text: 'posts'.tr()),
Tab(text: 'fediverseUsers'.tr()),
],
),
Expanded(
child: TabBarView(
controller: tabController,
children: [_PostsSearchTab(), _FediverseSearchTab()],
),
),
],
),
);
}
}
class _PostsSearchTab extends HookConsumerWidget {
const _PostsSearchTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
final searchController = useTextEditingController();
final debounce = useMemoized(() => Duration(milliseconds: 500));
final debounceTimer = useRef<Timer?>(null);
final showFilters = useState(false);
final pubNameController = useTextEditingController();
final realmController = useTextEditingController();
final categoryTabController = useTabController(initialLength: 3);
final queryState = useState(const PostListQuery());
final noti = ref.read(
postListProvider(PostListQueryConfig(id: kSearchPostListId)).notifier,
);
useEffect(() {
return () {
searchController.dispose();
pubNameController.dispose();
realmController.dispose();
debounceTimer.value?.cancel();
};
}, []);
void onSearchChanged(String query, {bool skipDebounce = false}) {
queryState.value = queryState.value.copyWith(queryTerm: query);
if (skipDebounce) {
noti.applyFilter(queryState.value);
return;
}
if (debounceTimer.value?.isActive ?? false) debounceTimer.value!.cancel();
debounceTimer.value = Timer(debounce, () {
noti.applyFilter(queryState.value);
});
}
void toggleFilterDisplay() {
showFilters.value = !showFilters.value;
}
Widget buildFilterPanel() {
return PostFilterWidget(
categoryTabController: categoryTabController,
initialQuery: queryState.value,
onQueryChanged: (newQuery) {
queryState.value = newQuery;
noti.applyFilter(newQuery);
},
hideSearch: true,
);
}
return Consumer(
builder: (context, ref, child) {
final searchState = ref.watch(
postListProvider(PostListQueryConfig(id: kSearchPostListId)),
);
return isWideScreen(context)
? Row(
children: [
Flexible(
flex: 4,
child: ExtendedRefreshIndicator(
onRefresh: noti.refresh,
child: CustomScrollView(
slivers: [
SliverGap(16),
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(
provider: postListProvider(
PostListQueryConfig(id: kSearchPostListId),
),
notifier: postListProvider(
PostListQueryConfig(id: kSearchPostListId),
).notifier,
isSliver: true,
isRefreshable: false,
footerSkeletonChild: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
),
child: const PostItemSkeleton(),
),
itemBuilder: (context, index, post) {
return Card(
margin: EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: PostActionableItem(
item: post,
borderRadius: 8,
),
);
},
),
if (searchState.value?.items.isEmpty == true &&
searchController.text.isNotEmpty &&
!searchState.isLoading)
SliverFillRemaining(
child: Center(child: Text('noResultsFound'.tr())),
),
SliverGap(MediaQuery.of(context).padding.bottom + 16),
],
).padding(left: 8),
),
),
Flexible(
flex: 3,
child: Align(
alignment: Alignment.topLeft,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Gap(16),
Card(
margin: EdgeInsets.symmetric(horizontal: 8),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Row(
children: [
const Icon(
Symbols.tune,
).padding(horizontal: 8),
Expanded(
child: Text(
'filters'.tr(),
style: Theme.of(
context,
).textTheme.bodyLarge,
),
),
IconButton(
icon: Icon(
Symbols.filter_alt,
fill: showFilters.value ? 1 : null,
),
onPressed: toggleFilterDisplay,
tooltip: 'toggleFilters'.tr(),
),
const Gap(4),
],
),
),
),
const Gap(8),
if (showFilters.value) buildFilterPanel(),
],
),
),
),
),
],
)
: CustomScrollView(
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);
},
),
),
),
if (showFilters.value)
SliverToBoxAdapter(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: buildFilterPanel(),
),
),
),
PaginationList(
provider: postListProvider(
PostListQueryConfig(id: kSearchPostListId),
),
notifier: postListProvider(
PostListQueryConfig(id: kSearchPostListId),
).notifier,
isSliver: true,
isRefreshable: false,
footerSkeletonChild: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: const PostItemSkeleton(),
),
itemBuilder: (context, index, post) {
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 600),
child: Card(
margin: EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: PostActionableItem(
item: post,
borderRadius: 8,
),
),
),
);
},
),
if (searchState.value?.items.isEmpty == true &&
searchController.text.isNotEmpty &&
!searchState.isLoading)
SliverFillRemaining(
child: Center(child: Text('noResultsFound'.tr())),
),
],
);
},
);
}
}
class _FediverseSearchTab extends HookConsumerWidget {
const _FediverseSearchTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
final searchController = useTextEditingController();
final debounce = useMemoized(() => const Duration(milliseconds: 500));
final debounceTimer = useRef<Timer?>(null);
final searchResults = useState<List<SnActivityPubActor>>([]);
final isSearching = useState(false);
useEffect(() {
return () {
searchController.dispose();
debounceTimer.value?.cancel();
};
}, []);
Future<void> performSearch(String query) async {
if (query.trim().isEmpty) {
searchResults.value = [];
return;
}
isSearching.value = true;
try {
final service = ref.read(activityPubServiceProvider);
final results = await service.searchUsers(query);
searchResults.value = results;
} catch (err) {
showErrorAlert(err);
} finally {
isSearching.value = false;
}
}
void onSearchChanged(String query) {
if (debounceTimer.value?.isActive ?? false) {
debounceTimer.value!.cancel();
}
debounceTimer.value = Timer(debounce, () {
performSearch(query);
});
}
void updateActorIsFollowing(String actorId, bool isFollowing) {
searchResults.value = searchResults.value
.map(
(a) => a.id == actorId ? a.copyWith(isFollowing: isFollowing) : a,
)
.toList();
}
Future<void> handleFollow(SnActivityPubActor actor) async {
try {
updateActorIsFollowing(actor.id, true);
final service = ref.read(activityPubServiceProvider);
await service.followRemoteUser(actor.uri);
showSnackBar(
'followedUser'.tr(
args: [
'${actor.username?.isNotEmpty ?? false ? actor.username : actor.displayName}',
],
),
);
} catch (err) {
showErrorAlert(err);
updateActorIsFollowing(actor.id, false);
}
}
Future<void> handleUnfollow(SnActivityPubActor actor) async {
try {
updateActorIsFollowing(actor.id, false);
final service = ref.read(activityPubServiceProvider);
await service.unfollowRemoteUser(actor.uri);
showSnackBar(
'unfollowedUser'.tr(
args: [
'${actor.username?.isNotEmpty ?? false ? actor.username : actor.displayName}',
],
),
);
} catch (err) {
showErrorAlert(err);
updateActorIsFollowing(actor.id, true);
}
}
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: SearchBar(
controller: searchController,
hintText: 'searchFediverseHint'.tr(
args: ['@username@instance.com'],
),
leading: const Icon(Symbols.search).padding(horizontal: 24),
onChanged: onSearchChanged,
onSubmitted: (value) {
onSearchChanged(value);
performSearch(value);
},
),
),
Expanded(
child: isSearching.value
? const Center(child: CircularProgressIndicator())
: searchResults.value.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Symbols.search,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
if (searchController.text.isEmpty)
Text(
'searchFediverseEmpty'.tr(),
style: Theme.of(context).textTheme.titleMedium,
)
else
Text(
'searchFediverseNoResults'.tr(),
style: Theme.of(context).textTheme.titleMedium,
),
],
),
)
: ExtendedRefreshIndicator(
onRefresh: () => performSearch(searchController.text),
child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: searchResults.value.length,
separatorBuilder: (context, index) => const Gap(8),
itemBuilder: (context, index) {
final actor = searchResults.value[index];
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: ApActorListItem(
actor: actor,
isFollowing: actor.isFollowing ?? false,
isLoading: false,
onFollow: () => handleFollow(actor),
onUnfollow: () => handleUnfollow(actor),
),
),
);
},
),
),
),
],
);
}
}