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('universalSearch'.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(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); }, ), ), ), const SliverGap(8), SliverToBoxAdapter( child: Column( mainAxisSize: MainAxisSize.min, children: [ 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(4), if (showFilters.value) 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(null); final searchResults = useState>([]); final isSearching = useState(false); useEffect(() { return () { searchController.dispose(); debounceTimer.value?.cancel(); }; }, []); Future 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 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 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), ), ), ); }, ), ), ), ], ); } }