From 3c45064e7ae165e66d1d9a5191ab5ca8a567d871 Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su Date: Tue, 13 Jan 2026 15:35:49 -0800 Subject: [PATCH 1/3] refactor: refactor search page with better architecture --- lib/src/app/utils/navigation.dart | 2 +- lib/src/app/widgets/bottom_nav_bar.dart | 2 +- .../data/repositories/search_repository.dart | 18 +- .../search/presentation/bloc/search_bloc.dart | 305 +++--- .../presentation/bloc/search_event.dart | 126 ++- .../presentation/bloc/search_state.dart | 111 ++- .../presentation/pages/search_page.dart | 910 +++--------------- .../presentation/widgets/search_body.dart | 391 ++++++++ .../widgets/search_comments_results.dart | 58 ++ .../widgets/search_communities_results.dart | 43 + .../widgets/search_filters_row.dart | 408 ++++++++ .../widgets/search_instances_results.dart | 47 + .../widgets/search_page_app_bar.dart | 72 ++ .../widgets/search_posts_results.dart | 90 ++ .../widgets/search_users_results.dart | 44 + 15 files changed, 1608 insertions(+), 1019 deletions(-) create mode 100644 lib/src/features/search/presentation/widgets/search_body.dart create mode 100644 lib/src/features/search/presentation/widgets/search_comments_results.dart create mode 100644 lib/src/features/search/presentation/widgets/search_communities_results.dart create mode 100644 lib/src/features/search/presentation/widgets/search_filters_row.dart create mode 100644 lib/src/features/search/presentation/widgets/search_instances_results.dart create mode 100644 lib/src/features/search/presentation/widgets/search_page_app_bar.dart create mode 100644 lib/src/features/search/presentation/widgets/search_posts_results.dart create mode 100644 lib/src/features/search/presentation/widgets/search_users_results.dart diff --git a/lib/src/app/utils/navigation.dart b/lib/src/app/utils/navigation.dart index 2808c982f..5697e1011 100644 --- a/lib/src/app/utils/navigation.dart +++ b/lib/src/app/utils/navigation.dart @@ -711,7 +711,7 @@ void navigateToSearchPage(BuildContext context) { BlocProvider(create: (context) => SearchBloc(account: account)), BlocProvider.value(value: thunderBloc), ], - child: SearchPage(communityToSearch: feedBloc.state.community, isInitiallyFocused: true), + child: SearchPage(community: feedBloc.state.community), ), ), ); diff --git a/lib/src/app/widgets/bottom_nav_bar.dart b/lib/src/app/widgets/bottom_nav_bar.dart index 503208fa3..5fcc01ad5 100644 --- a/lib/src/app/widgets/bottom_nav_bar.dart +++ b/lib/src/app/widgets/bottom_nav_bar.dart @@ -146,7 +146,7 @@ class _CustomBottomNavigationBarState extends State { if (widget.selectedPageIndex == 1 && index != 1) { FocusManager.instance.primaryFocus?.unfocus(); } else if (widget.selectedPageIndex == 1 && index == 1) { - context.read().add(FocusSearchEvent()); + context.read().add(SearchFocusRequested()); } if (widget.selectedPageIndex == 3 && index == 3) { diff --git a/lib/src/features/search/data/repositories/search_repository.dart b/lib/src/features/search/data/repositories/search_repository.dart index be2a5c7d7..dde6d2dd6 100644 --- a/lib/src/features/search/data/repositories/search_repository.dart +++ b/lib/src/features/search/data/repositories/search_repository.dart @@ -40,21 +40,21 @@ class SearchRepositoryImpl implements SearchRepository { /// The account to use for methods invoked in this repository Account account; - /// The Lemmy client to use for the repository - late LemmyApi lemmy; + /// The Lemmy client to use for the repository (initialized for Lemmy platforms) + LemmyApi? _lemmy; - /// The Piefed client to use for the repository - late PiefedApi piefed; + /// The Piefed client to use for the repository (initialized for Piefed platforms) + PiefedApi? _piefed; SearchRepositoryImpl({required this.account}) { final version = PlatformVersionCache().get(account.instance); switch (account.platform) { case ThreadiversePlatform.lemmy: - lemmy = LemmyApi(account: account, debug: kDebugMode, version: version); + _lemmy = LemmyApi(account: account, debug: kDebugMode, version: version); break; case ThreadiversePlatform.piefed: - piefed = PiefedApi(account: account, debug: kDebugMode, version: version); + _piefed = PiefedApi(account: account, debug: kDebugMode, version: version); break; default: throw Exception('Unsupported platform: ${account.platform}'); @@ -74,6 +74,7 @@ class SearchRepositoryImpl implements SearchRepository { }) async { switch (account.platform) { case ThreadiversePlatform.lemmy: + final lemmy = _lemmy!; final response = await lemmy.search( query: query, type: type, @@ -112,6 +113,7 @@ class SearchRepositoryImpl implements SearchRepository { 'users': users, }; case ThreadiversePlatform.piefed: + final piefed = _piefed!; final response = await piefed.search( query: query, type: type, @@ -156,9 +158,9 @@ class SearchRepositoryImpl implements SearchRepository { Future> resolve({required String query}) async { switch (account.platform) { case ThreadiversePlatform.lemmy: - return await lemmy.resolve(query: query); + return await _lemmy!.resolve(query: query); case ThreadiversePlatform.piefed: - return await piefed.resolve(query: query); + return await _piefed!.resolve(query: query); default: throw Exception('Unsupported platform: ${account.platform}'); } diff --git a/lib/src/features/search/presentation/bloc/search_bloc.dart b/lib/src/features/search/presentation/bloc/search_bloc.dart index 498c94333..ed04ab72e 100644 --- a/lib/src/features/search/presentation/bloc/search_bloc.dart +++ b/lib/src/features/search/presentation/bloc/search_bloc.dart @@ -1,14 +1,14 @@ +import 'package:flutter/widgets.dart'; + import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:stream_transform/stream_transform.dart'; -import 'package:collection/collection.dart'; import 'package:thunder/src/features/comment/comment.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/features/instance/instance.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/core/enums/enums.dart'; import 'package:thunder/src/core/enums/meta_search_type.dart'; @@ -17,7 +17,6 @@ import 'package:thunder/src/core/models/models.dart'; import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/features/search/search.dart'; import 'package:thunder/src/features/user/user.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; import 'package:thunder/src/shared/utils/instance.dart'; part 'search_event.dart'; @@ -25,83 +24,100 @@ part 'search_state.dart'; const throttleDuration = Duration(milliseconds: 300); const timeout = Duration(seconds: 10); +const searchResultsPerPage = 15; EventTransformer throttleDroppable(Duration duration) { return (events, mapper) => droppable().call(events.throttle(duration), mapper); } +/// Helper to count results for the current search type. +int _resultsCount( + MetaSearchType searchType, + List? communities, + List? users, + List? comments, + List? posts, +) { + return switch (searchType) { + MetaSearchType.communities => communities?.length ?? 0, + MetaSearchType.users => users?.length ?? 0, + MetaSearchType.comments => comments?.length ?? 0, + MetaSearchType.posts || MetaSearchType.url => posts?.length ?? 0, + _ => 0, + }; +} + class SearchBloc extends Bloc { + /// The account to use for repositories Account account; + /// The comment repository to use for comment operations late CommentRepository commentRepository; + + /// The search repository to use for search operations late SearchRepository searchRepository; + + /// The community repository to use for community operations late CommunityRepository communityRepository; + + /// The user repository to use for user operations late UserRepository userRepository; + /// The instance repository to use for instance operations + late InstanceRepository instanceRepository; + SearchBloc({required this.account}) : super(SearchState()) { commentRepository = CommentRepositoryImpl(account: account); searchRepository = SearchRepositoryImpl(account: account); communityRepository = CommunityRepositoryImpl(account: account); userRepository = UserRepositoryImpl(account: account); + instanceRepository = InstanceRepositoryImpl(account: account); - on( - _startSearchEvent, + on(_onSearchReset); + on( + _onSearchStarted, // Use restartable here so that a long search can essentially be "canceled" by a new one. // Note that we don't also need throttling because the search page text box has a debounce. transformer: restartable(), ); - on( - _changeCommunitySubsciptionStatusEvent, - transformer: throttleDroppable(Duration.zero), - ); - on( - _resetSearch, - transformer: throttleDroppable(throttleDuration), + on( + _onSearchContinued, + transformer: droppable(), ); - on( - _continueSearchEvent, - transformer: throttleDroppable(throttleDuration), + on( + _onSearchFocusRequested, + transformer: droppable(), ); - on( - _focusSearchEvent, - transformer: throttleDroppable(throttleDuration), - ); - on( - _getTrendingCommunitiesEvent, - transformer: throttleDroppable(throttleDuration), - ); - on( - _voteCommentEvent, - transformer: throttleDroppable(Duration.zero), // Don't give a throttle on vote - ); - on( - _saveCommentEvent, - transformer: throttleDroppable(Duration.zero), // Don't give a throttle on save + on( + _onTrendingCommunitiesRequested, + transformer: droppable(), ); + on(_onFiltersUpdated); } - Future _resetSearch(ResetSearch event, Emitter emit) async { + Future _onSearchReset(SearchReset event, Emitter emit) async { emit(state.copyWith(status: SearchStatus.initial, trendingCommunities: [], viewingAll: false)); - await _getTrendingCommunitiesEvent(GetTrendingCommunitiesEvent(), emit); + await _onTrendingCommunitiesRequested(const TrendingCommunitiesRequested(), emit); } - Future _startSearchEvent(StartSearchEvent event, Emitter emit) async { + Future _onSearchStarted(SearchStarted event, Emitter emit) async { try { - emit(state.copyWith(status: SearchStatus.loading)); + emit(state.copyWith(status: SearchStatus.loading, hasReachedMax: false)); if (event.query.isEmpty && event.force != true) return emit(state.copyWith(status: SearchStatus.initial)); - final account = await fetchActiveProfile(); - List? users; List? communities; List? comments; List? posts; List instances = []; - if (event.searchType == MetaSearchType.instances) { + final effectiveSearchType = state.effectiveSearchType; + + if (effectiveSearchType == MetaSearchType.instances) { // Retrieve all the federated instances from this instance. - final federatedInstances = await InstanceRepositoryImpl(account: account).federated(); - final linkedInstances = federatedInstances['federated_instances']['linked'] ?? []; + final federatedInstances = await instanceRepository.federated(); + final federatedInstancesMap = federatedInstances['federated_instances'] as Map?; + final linkedInstances = federatedInstancesMap?['linked'] ?? []; final filteredInstances = linkedInstances.where((instance) => instance['software'] == "lemmy" && instance['domain'].contains(event.query)).toList(); @@ -128,13 +144,13 @@ class SearchBloc extends Bloc { } else { final response = await searchRepository.search( query: event.query, - type: event.searchType, - sort: event.postSortType, - listingType: event.feedListType, - limit: 15, + type: effectiveSearchType, + sort: state.postSortType ?? PostSortType.active, + listingType: state.feedListType, + limit: searchResultsPerPage, page: 1, - communityId: event.communityId, - creatorId: event.creatorId, + communityId: state.communityFilter, + creatorId: state.creatorFilter, ); users = response['users']; @@ -144,31 +160,30 @@ class SearchBloc extends Bloc { } // If there are no search results, see if this is an exact search - if (event.searchType == MetaSearchType.communities && communities?.isEmpty == true) { + if (effectiveSearchType == MetaSearchType.communities && communities?.isEmpty == true) { // Note: We could jump straight to GetCommunity here. // However, getLemmyCommunity has a nice instance check that can short-circuit things // if the instance is not valid to start. String? communityName = await getLemmyCommunity(event.query); if (communityName != null) { try { - final account = await fetchActiveProfile(); - final response = await CommunityRepositoryImpl(account: account).getCommunity(name: communityName); + final response = await communityRepository.getCommunity(name: communityName); communities = [response['community']]; } catch (e) { - // Ignore any exceptions here and return an empty response below + debugPrint('SearchBloc: Failed to fetch community by name: $e'); } } } // Check for exact user search - if (event.searchType == MetaSearchType.users && users?.isEmpty == true) { + if (effectiveSearchType == MetaSearchType.users && users?.isEmpty == true) { String? userName = await getLemmyUser(event.query); if (userName != null) { try { final response = await userRepository.getUser(username: userName); users = [response!['user']]; } catch (e) { - // Ignore any exceptions here and return an empty response below + debugPrint('SearchBloc: Failed to fetch user by name: $e'); } } } @@ -184,11 +199,14 @@ class SearchBloc extends Bloc { viewingAll: event.query.isEmpty, )); } catch (e) { - return emit(state.copyWith(status: SearchStatus.failure, errorMessage: e.toString())); + return emit(state.copyWith(status: SearchStatus.failure, message: e.toString())); } } - Future _continueSearchEvent(ContinueSearchEvent event, Emitter emit) async { + Future _onSearchContinued(SearchContinued event, Emitter emit) async { + // Early exit if pagination is exhausted + if (state.hasReachedMax) return; + int attemptCount = 0; try { @@ -196,11 +214,6 @@ class SearchBloc extends Bloc { try { emit(state.copyWith( status: SearchStatus.refreshing, - communities: state.communities, - users: state.users, - comments: state.comments, - posts: state.posts, - instances: state.instances, )); List? users; @@ -208,18 +221,20 @@ class SearchBloc extends Bloc { List? comments; List? posts; - if (event.searchType == MetaSearchType.instances) { + final effectiveSearchType = state.effectiveSearchType; + + if (effectiveSearchType == MetaSearchType.instances) { // Instance search is not paged, so this is a no-op. } else { final response = await searchRepository.search( query: event.query, - type: event.searchType, - sort: event.postSortType, - listingType: event.feedListType, - limit: 15, + type: effectiveSearchType, + sort: state.postSortType ?? PostSortType.active, + listingType: state.feedListType, + limit: searchResultsPerPage, page: state.page, - communityId: event.communityId, - creatorId: event.creatorId, + communityId: state.communityFilter, + creatorId: state.creatorFilter, ); users = response['users']; @@ -228,8 +243,8 @@ class SearchBloc extends Bloc { posts = response['posts']; } - if (searchIsEmpty(event.searchType, searchResponse: {'users': users, 'communities': communities, 'comments': comments, 'posts': posts})) { - return emit(state.copyWith(status: SearchStatus.done)); + if (searchIsEmpty(effectiveSearchType, searchResponse: {'users': users, 'communities': communities, 'comments': comments, 'posts': posts})) { + return emit(state.copyWith(status: SearchStatus.success, hasReachedMax: true)); } // Append the search results @@ -244,152 +259,54 @@ class SearchBloc extends Bloc { users: allUsers, comments: allComments, posts: allPosts, - instances: state.instances, page: state.page + 1, + hasReachedMax: _resultsCount(effectiveSearchType, communities, users, comments, posts) < searchResultsPerPage, )); } catch (e) { attemptCount++; + debugPrint('SearchBloc: Continue search attempt $attemptCount failed: $e'); + if (attemptCount >= 2) { + return emit(state.copyWith(status: SearchStatus.failure, message: e.toString())); + } + await Future.delayed(const Duration(milliseconds: 500)); } } } catch (e) { - return emit(state.copyWith(status: SearchStatus.failure, errorMessage: e.toString())); + debugPrint('SearchBloc: Continue search failed: $e'); + return emit(state.copyWith(status: SearchStatus.failure, message: e.toString())); } } - Future _focusSearchEvent(FocusSearchEvent event, Emitter emit) async { + Future _onSearchFocusRequested(SearchFocusRequested event, Emitter emit) async { emit(state.copyWith(focusSearchId: state.focusSearchId + 1)); } - Future _changeCommunitySubsciptionStatusEvent(ChangeCommunitySubsciptionStatusEvent event, Emitter emit) async { - try { - if (event.query.isNotEmpty) { - emit(state.copyWith(status: SearchStatus.refreshing, communities: state.communities)); - } - - final l10n = AppLocalizations.of(GlobalContext.context)!; - final account = await fetchActiveProfile(); - if (account.anonymous) throw Exception(l10n.userNotLoggedIn); - - await CommunityRepositoryImpl(account: account).subscribe(event.communityId, event.follow); - - // Refetch the status of the community - communityResponse does not return back with the proper subscription status - Map response = await CommunityRepositoryImpl(account: account).getCommunity(id: event.communityId); - ThunderCommunity community = response['community']; - - List communities; - - if (event.query.isNotEmpty || state.viewingAll) { - communities = state.communities ?? []; - - communities = state.communities?.map((ThunderCommunity c) { - if (c.id == community.id) return community; - return c; - }).toList() ?? - []; - - emit(state.copyWith(status: SearchStatus.success, communities: communities)); - } else { - communities = state.trendingCommunities ?? []; - - communities = state.trendingCommunities?.map((ThunderCommunity c) { - if (c.id == community.id) return community; - return c; - }).toList() ?? - []; - - emit(state.copyWith(status: SearchStatus.trending, trendingCommunities: communities)); - } - - // Delay a bit then refetch the status of the community again for a better chance of getting the right subscribed type - await Future.delayed(const Duration(seconds: 1)); - - response = await CommunityRepositoryImpl(account: account).getCommunity(id: event.communityId); - community = response['community']; - - if (event.query.isNotEmpty || state.viewingAll) { - communities = state.communities ?? []; - - communities = state.communities?.map((ThunderCommunity c) { - if (c.id == community.id) return community; - return c; - }).toList() ?? - []; - - return emit(state.copyWith(status: event.query.isNotEmpty || state.viewingAll ? SearchStatus.success : SearchStatus.trending, communities: communities)); - } else { - communities = state.trendingCommunities ?? []; - - communities = state.trendingCommunities?.map((ThunderCommunity c) { - if (c.id == community.id) return community; - return c; - }).toList() ?? - []; - - return emit(state.copyWith(status: SearchStatus.trending, trendingCommunities: communities)); - } - } catch (e) { - return emit(state.copyWith(status: SearchStatus.failure, errorMessage: e.toString())); - } - } - - Future _getTrendingCommunitiesEvent(GetTrendingCommunitiesEvent event, Emitter emit) async { + Future _onTrendingCommunitiesRequested(TrendingCommunitiesRequested event, Emitter emit) async { try { final communities = await communityRepository.trending(); return emit(state.copyWith(status: SearchStatus.trending, trendingCommunities: communities)); } catch (e) { - // Not the end of the world if we can't load trending + debugPrint('SearchBloc: Failed to load trending communities: $e'); + return emit(state.copyWith(status: SearchStatus.trending, trendingCommunities: [])); } } - Future _voteCommentEvent(VoteCommentEvent event, Emitter emit) async { - final AppLocalizations l10n = AppLocalizations.of(GlobalContext.context)!; - - emit(state.copyWith(status: SearchStatus.performingCommentAction)); - - try { - ThunderComment? comment = state.comments?.firstWhereOrNull((comment) => comment.id == event.commentId); - if (comment == null) return; - - ThunderComment updatedComment = await commentRepository.vote(comment, event.score).timeout(timeout, onTimeout: () { - throw Exception(l10n.timeoutUpvoteComment); - }); - - // If it worked, update and emit - int index = (state.comments?.indexOf(comment))!; - - List comments = List.from(state.comments ?? []); - comments.insert(index, updatedComment); - comments.remove(comment); - - emit(state.copyWith(status: SearchStatus.success, comments: comments)); - } catch (e) { - // It just fails - } - } - - Future _saveCommentEvent(SaveCommentEvent event, Emitter emit) async { - final AppLocalizations l10n = AppLocalizations.of(GlobalContext.context)!; - - emit(state.copyWith(status: SearchStatus.performingCommentAction)); - - try { - ThunderComment? comment = state.comments?.firstWhereOrNull((comment) => comment.id == event.commentId); - if (comment == null) return; - - ThunderComment updatedComment = await commentRepository.save(comment, event.save).timeout(timeout, onTimeout: () { - throw Exception(l10n.timeoutUpvoteComment); - }); - - // If it worked, update and emit - int index = (state.comments?.indexOf(comment))!; - - List comments = List.from(state.comments ?? []); - comments.insert(index, updatedComment); - comments.remove(comment); - - emit(state.copyWith(status: SearchStatus.success, comments: comments)); - } catch (e) { - // It just fails - } + void _onFiltersUpdated(SearchFiltersUpdated event, Emitter emit) { + emit( + state.copyWith( + postSortType: event.sortType, + sortTypeIcon: event.sortTypeIcon, + sortTypeLabel: event.sortTypeLabel, + searchType: event.searchType, + feedListType: event.feedListType, + searchByUrl: event.searchByUrl, + communityFilter: event.communityFilter, + communityFilterName: event.communityFilterName, + clearCommunityFilter: event.clearCommunityFilter, + creatorFilter: event.creatorFilter, + creatorFilterName: event.creatorFilterName, + clearCreatorFilter: event.clearCreatorFilter, + ), + ); } } diff --git a/lib/src/features/search/presentation/bloc/search_event.dart b/lib/src/features/search/presentation/bloc/search_event.dart index 524ac8388..66eef9803 100644 --- a/lib/src/features/search/presentation/bloc/search_event.dart +++ b/lib/src/features/search/presentation/bloc/search_event.dart @@ -1,78 +1,108 @@ part of 'search_bloc.dart'; -abstract class SearchEvent extends Equatable { +sealed class SearchEvent extends Equatable { const SearchEvent(); @override - List get props => []; + List get props => []; } -class StartSearchEvent extends SearchEvent { +/// Started a new search with the given query using the current filter state. +final class SearchStarted extends SearchEvent { + /// The query to search for final String query; - final PostSortType postSortType; - final FeedListType feedListType; - final MetaSearchType searchType; - final int? communityId; - final int? creatorId; + + /// Whether to force a new search (useful for viewing all items for a given search type) + final bool force; + + /// The favorite communities final List? favoriteCommunities; - final bool? force; - const StartSearchEvent({ + const SearchStarted({ required this.query, - required this.postSortType, - required this.feedListType, - required this.searchType, - this.communityId, - this.creatorId, + this.force = false, this.favoriteCommunities, - this.force, }); -} - -class ChangeCommunitySubsciptionStatusEvent extends SearchEvent { - final int communityId; - final bool follow; - final String query; - const ChangeCommunitySubsciptionStatusEvent({required this.communityId, required this.follow, required this.query}); + @override + List get props => [query, force, favoriteCommunities]; } -class ResetSearch extends SearchEvent {} +/// Reset the search to initial state. +final class SearchReset extends SearchEvent { + const SearchReset(); +} -class ContinueSearchEvent extends SearchEvent { +/// Continued pagination for the current search. +final class SearchContinued extends SearchEvent { + /// The query to search for final String query; - final PostSortType postSortType; - final FeedListType feedListType; - final MetaSearchType searchType; - final int? communityId; - final int? creatorId; + + /// The favorite communities final List? favoriteCommunities; - const ContinueSearchEvent({ + const SearchContinued({ required this.query, - required this.postSortType, - required this.feedListType, - required this.searchType, - this.communityId, - this.creatorId, this.favoriteCommunities, }); -} - -class FocusSearchEvent extends SearchEvent {} -class GetTrendingCommunitiesEvent extends SearchEvent {} + @override + List get props => [query, favoriteCommunities]; +} -class VoteCommentEvent extends SearchEvent { - final int commentId; - final int score; +/// Requested focus on the search field. +final class SearchFocusRequested extends SearchEvent { + const SearchFocusRequested(); +} - const VoteCommentEvent({required this.commentId, required this.score}); +/// Requested trending communities. +final class TrendingCommunitiesRequested extends SearchEvent { + const TrendingCommunitiesRequested(); } -class SaveCommentEvent extends SearchEvent { - final int commentId; - final bool save; +/// Updated the search filters. +class SearchFiltersUpdated extends SearchEvent { + final PostSortType? sortType; + final IconData? sortTypeIcon; + final String? sortTypeLabel; + final MetaSearchType? searchType; + final FeedListType? feedListType; + final bool? searchByUrl; + final int? communityFilter; + final String? communityFilterName; + final bool clearCommunityFilter; + final int? creatorFilter; + final String? creatorFilterName; + final bool clearCreatorFilter; + + const SearchFiltersUpdated({ + this.sortType, + this.sortTypeIcon, + this.sortTypeLabel, + this.searchType, + this.feedListType, + this.searchByUrl, + this.communityFilter, + this.communityFilterName, + this.clearCommunityFilter = false, + this.creatorFilter, + this.creatorFilterName, + this.clearCreatorFilter = false, + }); - const SaveCommentEvent({required this.commentId, required this.save}); + @override + List get props => [ + sortType, + sortTypeIcon, + sortTypeLabel, + searchType, + feedListType, + searchByUrl, + communityFilter, + communityFilterName, + clearCommunityFilter, + creatorFilter, + creatorFilterName, + clearCreatorFilter, + ]; } diff --git a/lib/src/features/search/presentation/bloc/search_state.dart b/lib/src/features/search/presentation/bloc/search_state.dart index 9546f7c60..b6b7a4751 100644 --- a/lib/src/features/search/presentation/bloc/search_state.dart +++ b/lib/src/features/search/presentation/bloc/search_state.dart @@ -11,29 +11,92 @@ class SearchState extends Equatable { this.comments, this.posts, this.instances, - this.errorMessage, + this.message, this.page = 1, + this.hasReachedMax = false, this.postSortType, + this.sortTypeIcon, + this.sortTypeLabel, this.focusSearchId = 0, this.viewingAll = false, + this.searchType = MetaSearchType.communities, + this.feedListType = FeedListType.all, + this.searchByUrl = false, + this.communityFilter, + this.communityFilterName, + this.creatorFilter, + this.creatorFilterName, }); + /// The current status of the search final SearchStatus status; + + /// The type of search being performed + final MetaSearchType searchType; + + /// The type of feed list being displayed + final FeedListType feedListType; + + /// The sort type to use for the search + final PostSortType? postSortType; + + /// The icon for the sort type + final IconData? sortTypeIcon; + + /// The label for the sort type + final String? sortTypeLabel; + + /// The community filter for the search + final int? communityFilter; + + /// The name of the community filter for the search + final String? communityFilterName; + + /// The creator filter for the search + final int? creatorFilter; + + /// The name of the creator filter for the search + final String? creatorFilterName; + + /// The communities found by the search final List? communities; + + /// The trending communities final List? trendingCommunities; + + /// The users found by the search final List? users; + + /// The comments found by the search final List? comments; + + /// The posts found by the search final List? posts; + + /// The instances found by the search final List? instances; - final String? errorMessage; + /// The error message to display for errors + final String? message; + /// The current page of the search for the specific search type final int page; - final PostSortType? postSortType; + /// Whether the search has reached the maximum number of results + final bool hasReachedMax; + + /// Used to focus on the search field if incremented final int focusSearchId; + + /// Whether the search is viewing all results final bool viewingAll; + /// Whether the search is using the URL search mode + final bool searchByUrl; + + /// Returns the effective search type + MetaSearchType get effectiveSearchType => searchType == MetaSearchType.posts && searchByUrl ? MetaSearchType.url : searchType; + SearchState copyWith({ SearchStatus? status, List? communities, @@ -42,11 +105,23 @@ class SearchState extends Equatable { List? comments, List? posts, List? instances, - String? errorMessage, + String? message, int? page, + bool? hasReachedMax, PostSortType? postSortType, + IconData? sortTypeIcon, + String? sortTypeLabel, int? focusSearchId, bool? viewingAll, + MetaSearchType? searchType, + FeedListType? feedListType, + bool? searchByUrl, + int? communityFilter, + String? communityFilterName, + int? creatorFilter, + String? creatorFilterName, + bool clearCommunityFilter = false, + bool clearCreatorFilter = false, }) { return SearchState( status: status ?? this.status, @@ -56,11 +131,21 @@ class SearchState extends Equatable { comments: comments ?? this.comments, posts: posts ?? this.posts, instances: instances ?? this.instances, - errorMessage: errorMessage, + message: message ?? this.message, page: page ?? this.page, + hasReachedMax: hasReachedMax ?? this.hasReachedMax, postSortType: postSortType ?? this.postSortType, + sortTypeIcon: sortTypeIcon ?? this.sortTypeIcon, + sortTypeLabel: sortTypeLabel ?? this.sortTypeLabel, focusSearchId: focusSearchId ?? this.focusSearchId, viewingAll: viewingAll ?? this.viewingAll, + searchType: searchType ?? this.searchType, + feedListType: feedListType ?? this.feedListType, + searchByUrl: searchByUrl ?? this.searchByUrl, + communityFilter: clearCommunityFilter ? null : (communityFilter ?? this.communityFilter), + communityFilterName: clearCommunityFilter ? null : (communityFilterName ?? this.communityFilterName), + creatorFilter: clearCreatorFilter ? null : (creatorFilter ?? this.creatorFilter), + creatorFilterName: clearCreatorFilter ? null : (creatorFilterName ?? this.creatorFilterName), ); } @@ -70,9 +155,23 @@ class SearchState extends Equatable { communities, trendingCommunities, users, - errorMessage, + comments, + posts, + instances, + message, page, + hasReachedMax, + postSortType, + sortTypeIcon, + sortTypeLabel, focusSearchId, viewingAll, + searchType, + feedListType, + searchByUrl, + communityFilter, + communityFilterName, + creatorFilter, + creatorFilterName, ]; } diff --git a/lib/src/features/search/presentation/pages/search_page.dart b/lib/src/features/search/presentation/pages/search_page.dart index e86441bd0..0908565b7 100644 --- a/lib/src/features/search/presentation/pages/search_page.dart +++ b/lib/src/features/search/presentation/pages/search_page.dart @@ -1,50 +1,32 @@ import 'dart:async'; -import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:back_button_interceptor/back_button_interceptor.dart'; -import 'package:fading_edge_scrollview/fading_edge_scrollview.dart'; -import 'package:flex_color_scheme/flex_color_scheme.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/src/features/community/community.dart'; -import 'package:thunder/src/core/enums/threadiverse_platform.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/features/comment/comment.dart'; -import 'package:thunder/src/core/enums/enums.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; -import 'package:thunder/src/core/enums/full_name.dart'; import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/models/models.dart'; +import 'package:thunder/src/core/enums/post_sort_type.dart'; import 'package:thunder/src/core/singletons/preferences.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/features/feed/feed.dart'; -import 'package:thunder/src/app/utils/navigation.dart'; -import 'package:thunder/src/features/instance/instance.dart'; import 'package:thunder/src/features/search/search.dart'; -import 'package:thunder/src/shared/widgets/chips/thunder_action_chip.dart'; -import 'package:thunder/src/shared/error_message.dart'; -import 'package:thunder/src/shared/input_dialogs.dart'; +import 'package:thunder/src/features/search/presentation/widgets/search_body.dart'; +import 'package:thunder/src/features/search/presentation/widgets/search_filters_row.dart'; +import 'package:thunder/src/features/search/presentation/widgets/search_page_app_bar.dart'; import 'package:thunder/src/shared/sort_picker.dart'; -import 'package:thunder/src/app/bloc/thunder_bloc.dart'; -import 'package:thunder/src/features/user/user.dart'; import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; import 'package:thunder/src/shared/utils/constants.dart'; import 'package:thunder/src/shared/utils/debounce.dart'; import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/shared/utils/instance.dart'; +/// The main search page that handles search functionality. class SearchPage extends StatefulWidget { - /// Allows the search page to limited to searching a specific community - final ThunderCommunity? communityToSearch; + /// Limits the search to a specific community. + final ThunderCommunity? community; - /// Whether the search field is initially focused upon opening this page - final bool isInitiallyFocused; - - const SearchPage({super.key, this.communityToSearch, this.isInitiallyFocused = false}); + const SearchPage({super.key, this.community}); @override State createState() => _SearchPageState(); @@ -54,737 +36,114 @@ class _SearchPageState extends State with AutomaticKeepAliveClientMi @override bool get wantKeepAlive => true; - final TextEditingController _controller = TextEditingController(); - final _scrollController = ScrollController(initialScrollOffset: 0); - // This exists only because it is required by FadingEdgeScrollView - final ScrollController _searchFiltersScrollController = ScrollController(); - PostSortType postSortType = PostSortType.active; - IconData? postSortTypeIcon; - String? postSortTypeLabel; - int _previousFocusSearchId = 0; + /// Controller for the search text field. + final controller = TextEditingController(); + + /// Controller for the scroll view. + final scrollController = ScrollController(); + + /// Focus node for the search text field. final searchTextFieldFocus = FocusNode(); - int? _previousUserId; - int? _previousFavoritesCount; - - late MetaSearchType _currentSearchType; - FeedListType _currentFeedType = FeedListType.all; - IconData? _feedTypeIcon = Icons.grid_view_rounded; - String? _feedTypeLabel = AppLocalizations.of(GlobalContext.context)!.all; - bool _searchByUrl = false; - String _searchUrlLabel = AppLocalizations.of(GlobalContext.context)!.text; - String? _currentCommunityFilterName; - int? _currentCommunityFilter; - String? _currentCreatorFilterName; - int? _currentCreatorFilter; + + /// Previous focus search ID. This is used to trigger the search text field focus. + int previousFocusSearchId = 0; @override void initState() { - _currentSearchType = widget.communityToSearch == null ? MetaSearchType.communities : MetaSearchType.posts; - _scrollController.addListener(_onScroll); - initPrefs(); - fetchActiveProfile().then((activeProfile) => _previousUserId = activeProfile.userId); - context.read().add(GetTrendingCommunitiesEvent()); + super.initState(); - if (widget.isInitiallyFocused) { + initializePreferences(); + scrollController.addListener(onScroll); + + // Initialize search type based on whether we're searching within a community + if (widget.community != null) { + context.read().add(const SearchFiltersUpdated(searchType: MetaSearchType.posts)); WidgetsBinding.instance.addPostFrameCallback((_) => searchTextFieldFocus.requestFocus()); } - BackButtonInterceptor.add(_handleBackButtonPress); - super.initState(); - } + context.read().add(const TrendingCommunitiesRequested()); - Future initPrefs() async { - setState(() { - postSortType = PostSortType.values.byName(UserPreferences.instance.preferences.getString("search_default_sort_type") ?? DEFAULT_SEARCH_POST_SORT_TYPE.name); - final postSortTypeItem = allPostSortTypeItems.firstWhere((item) => item.payload == postSortType); - postSortTypeIcon = postSortTypeItem.icon; - postSortTypeLabel = postSortTypeItem.label; - }); + BackButtonInterceptor.add(onBackButtonPress); } @override void dispose() { - _controller.dispose(); - _scrollController.dispose(); + controller.dispose(); + scrollController.dispose(); + searchTextFieldFocus.dispose(); + BackButtonInterceptor.remove(onBackButtonPress); super.dispose(); } - FutureOr _handleBackButtonPress(bool stopDefaultButtonEvent, RouteInfo info) async { + void initializePreferences() { + final prefs = UserPreferences.instance.preferences; + + final sortType = PostSortType.values.byName(prefs.getString("search_default_sort_type") ?? DEFAULT_SEARCH_POST_SORT_TYPE.name); + final sortTypeItem = allPostSortTypeItems.firstWhere((item) => item.payload == sortType); + + context.read().add(SearchFiltersUpdated(sortType: sortType, sortTypeIcon: sortTypeItem.icon, sortTypeLabel: sortTypeItem.label)); + } + + FutureOr onBackButtonPress(bool stopDefaultButtonEvent, RouteInfo info) async { if (searchTextFieldFocus.hasFocus) searchTextFieldFocus.unfocus(); return false; } - void _onScroll() { - if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent * 0.8) { - if (context.read().state.status != SearchStatus.done) { - context.read().add(ContinueSearchEvent( - query: _controller.text, - postSortType: postSortType, - feedListType: _currentFeedType, - searchType: _getSearchTypeToUse(), - communityId: widget.communityToSearch?.id ?? _currentCommunityFilter, - creatorId: _currentCreatorFilter, - favoriteCommunities: context.read().state.favorites, - )); + void onScroll() { + if (scrollController.position.pixels >= scrollController.position.maxScrollExtent * 0.8) { + final bloc = context.read(); + final favorites = context.read().state.favorites; + + if (bloc.state.status != SearchStatus.done) { + bloc.add(SearchContinued(query: controller.text, favoriteCommunities: favorites)); } } } - void resetTextField() { - searchTextFieldFocus.requestFocus(); - _controller.clear(); // Clear the search field - } + void onSearchFieldChanged(String value) { + final bloc = context.read(); - void _onChange(BuildContext context, String value) { - if (_currentSearchType == MetaSearchType.posts && Uri.tryParse(value)?.isAbsolute == true) { - setState(() { - _searchByUrl = true; - _searchUrlLabel = AppLocalizations.of(context)!.url; - }); + // Auto-detect URL mode for post searches + if (bloc.state.searchType == MetaSearchType.posts && Uri.tryParse(value)?.isAbsolute == true) { + bloc.add(const SearchFiltersUpdated(searchByUrl: true)); } - _doSearch(); + search(); } - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l10n = AppLocalizations.of(context)!; - - super.build(context); - - context.read().add(GetSubscribedCommunitiesEvent()); - - final account = context.select((bloc) => bloc.state.account); + void onClearSearch() { + searchTextFieldFocus.requestFocus(); + controller.clear(); - List searchOptions = [ - ListPickerItem(label: l10n.communities, payload: MetaSearchType.communities, icon: Icons.people_rounded), - ListPickerItem(label: l10n.users, payload: MetaSearchType.users, icon: Icons.person_rounded), - ListPickerItem(label: l10n.posts, payload: MetaSearchType.posts, icon: Icons.wysiwyg_rounded), - ListPickerItem(label: l10n.comments, payload: MetaSearchType.comments, icon: Icons.chat_rounded), - ListPickerItem(label: l10n.instance(2), payload: MetaSearchType.instances, icon: Icons.language), - ]; + context.read().add(const SearchReset()); + } - // Only keep post/comment for community search - if (widget.communityToSearch != null) { - searchOptions = searchOptions.where((option) => option.payload == MetaSearchType.posts || option.payload == MetaSearchType.comments).toList(); - } + void search({bool force = false}) { + final bloc = context.read(); - // PieFed only supports communities, posts, users, url - if (account.platform == ThreadiversePlatform.piefed) { - searchOptions = searchOptions - .where((option) => option.payload == MetaSearchType.communities || option.payload == MetaSearchType.posts || option.payload == MetaSearchType.users || option.payload == MetaSearchType.url) - .toList(); + // Update community filter from widget if searching within a community + if (widget.community != null && bloc.state.communityFilter != widget.community?.id) { + final community = widget.community!; + bloc.add(SearchFiltersUpdated(communityFilter: community.id, communityFilterName: community.name)); } - return BlocProvider( - create: (context) => FeedBloc(account: account), - child: MultiBlocListener( - listeners: [ - BlocListener(listener: (context, state) => setState(() {})), - BlocListener(listener: (context, state) {}), - BlocListener(listener: (context, state) => context.read().add(PopulatePostsEvent(state.posts ?? []))), - BlocListener(listener: (context, state) async { - final activeProfile = await fetchActiveProfile(); - - // When account changes, that means our instance most likely changed, so reset search. - if (state.status == ProfileStatus.success && ((activeProfile.userId == null && _previousUserId != null) || state.user?.id == activeProfile.userId && _previousUserId != state.user?.id) || - (state.favorites.length != _previousFavoritesCount && _controller.text.isEmpty)) { - _controller.clear(); - if (context.mounted) context.read().add(ResetSearch()); - setState(() {}); - _previousUserId = activeProfile.userId; - _previousFavoritesCount = state.favorites.length; - } - }), - BlocListener( - listener: (context, state) { - _controller.clear(); - context.read().add(ResetSearch()); - setState(() {}); - _previousUserId = null; - }, - ), - ], - child: BlocBuilder( - builder: (context, searchState) { - if (searchState.focusSearchId > _previousFocusSearchId) { - searchTextFieldFocus.requestFocus(); - _previousFocusSearchId = searchState.focusSearchId; - } - - return Scaffold( - appBar: AppBar( - toolbarHeight: 90.0, - scrolledUnderElevation: 0.0, - title: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(50), - child: Stack( - children: [ - TextField( - keyboardType: (!kIsWeb && Platform.isIOS) ? TextInputType.text : TextInputType.url, - focusNode: searchTextFieldFocus, - onChanged: (value) => debounce(const Duration(milliseconds: 300), _onChange, [context, value]), - controller: _controller, - onTap: () { - HapticFeedback.selectionClick(); - }, - decoration: InputDecoration( - fillColor: Theme.of(context).searchViewTheme.backgroundColor, - hintText: l10n.searchInstance(widget.communityToSearch?.name ?? account.instance), - filled: true, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(50), - borderSide: const BorderSide( - width: 0, - style: BorderStyle.none, - ), - ), - suffixIcon: _controller.text.isNotEmpty - ? SizedBox( - width: 50, - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - IconButton( - icon: Icon( - Icons.close, - semanticLabel: l10n.clearSearch, - ), - onPressed: () { - resetTextField(); - context.read().add(ResetSearch()); - }, - ), - ], - ), - ) - : null, - prefixIcon: const Icon(Icons.search_rounded), - contentPadding: const EdgeInsets.fromLTRB(12, 20, 12, 12), - ), - ), - ], - ), - )), - body: Stack( - children: [ - Padding( - padding: const EdgeInsets.only(left: 15, top: 10, right: 15), - child: FadingEdgeScrollView.fromSingleChildScrollView( - gradientFractionOnStart: 0.1, - gradientFractionOnEnd: 0.1, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: _searchFiltersScrollController, - child: Row( - children: [ - if (searchState.viewingAll) ...[ - ThunderActionChip( - backgroundColor: theme.colorScheme.primaryContainer.withValues(alpha: 0.25), - trailingIcon: Icons.close_rounded, - label: l10n.viewingAll, - onPressed: () => context.read().add(ResetSearch()), - ), - const SizedBox(width: 10), - ], - ThunderActionChip( - trailingIcon: Icons.arrow_drop_down_rounded, - label: _currentSearchType.name.capitalize, - onPressed: () { - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (ctx) => BottomSheetListPicker( - title: l10n.selectSearchType, - items: searchOptions, - onSelect: (value) async => _setCurrentSearchType(value.payload), - previouslySelected: _currentSearchType, - ), - ); - }, - ), - const SizedBox(width: 10), - if (_currentSearchType == MetaSearchType.posts) ...[ - ThunderActionChip( - icon: Icons.link_rounded, - trailingIcon: Icons.arrow_drop_down_rounded, - label: _searchUrlLabel, - onPressed: () { - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (ctx) => BottomSheetListPicker( - title: l10n.searchPostSearchType, - items: [ - ListPickerItem(label: l10n.searchByText, payload: 'text', icon: Icons.wysiwyg_rounded), - ListPickerItem(label: l10n.searchByUrl, payload: 'url', icon: Icons.link_rounded), - ], - onSelect: (value) async { - setState(() { - _searchByUrl = value.payload == 'url'; - _searchUrlLabel = value.payload == 'url' ? l10n.url : l10n.text; - }); - _doSearch(); - }, - previouslySelected: _searchByUrl ? 'url' : 'text', - ), - ); - }, - ), - const SizedBox(width: 10), - ], - if (_currentSearchType != MetaSearchType.instances) ...[ - ThunderActionChip( - icon: postSortTypeIcon, - trailingIcon: Icons.arrow_drop_down_rounded, - label: postSortTypeLabel ?? l10n.sortBy, - onPressed: () => showSortBottomSheet(context), - ), - if (widget.communityToSearch == null) ...[ - const SizedBox(width: 10), - ThunderActionChip( - icon: _feedTypeIcon, - trailingIcon: Icons.arrow_drop_down_rounded, - label: _feedTypeLabel ?? l10n.feed, - onPressed: () { - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (ctx) => BottomSheetListPicker( - title: l10n.selectFeedType, - items: [ - ListPickerItem(label: l10n.subscribed, payload: FeedListType.subscribed, icon: Icons.view_list_rounded), - ListPickerItem(label: l10n.local, payload: FeedListType.local, icon: Icons.home_rounded), - ListPickerItem(label: l10n.all, payload: FeedListType.all, icon: Icons.grid_view_rounded) - ], - onSelect: (value) async { - setState(() { - if (value.payload == FeedListType.subscribed) { - _feedTypeLabel = l10n.subscribed; - _feedTypeIcon = Icons.view_list_rounded; - } else if (value.payload == FeedListType.local) { - _feedTypeLabel = l10n.local; - _feedTypeIcon = Icons.home_rounded; - } else if (value.payload == FeedListType.all) { - _feedTypeLabel = l10n.all; - _feedTypeIcon = Icons.grid_view_rounded; - } - _currentFeedType = value.payload; - }); - _doSearch(); - }, - previouslySelected: _currentFeedType, - ), - ); - }, - ), - const SizedBox(width: 10), - ThunderActionChip( - backgroundColor: _currentCommunityFilter == null ? null : theme.colorScheme.primaryContainer.withValues(alpha: 0.25), - icon: Icons.people_rounded, - trailingIcon: _currentCommunityFilter != null ? Icons.close_rounded : Icons.arrow_drop_down_rounded, - label: _currentCommunityFilter == null ? l10n.community : l10n.filteringBy(_currentCommunityFilterName ?? ''), - onPressed: () { - if (_currentCommunityFilter != null) { - setState(() { - _currentCommunityFilter = null; - _currentCommunityFilterName = null; - }); - _doSearch(); - } else { - showCommunityInputDialog( - context, - title: l10n.community, - account: account, - onCommunitySelected: (ThunderCommunity community) { - setState(() { - _currentCommunityFilter = community.id; - _currentCommunityFilterName = generateCommunityFullName( - context, - community.name, - community.title, - fetchInstanceNameFromUrl(community.actorId), - ); - }); - _doSearch(); - }, - ); - } - }, - ), - ], - const SizedBox(width: 10), - ThunderActionChip( - backgroundColor: _currentCreatorFilter == null ? null : theme.colorScheme.primaryContainer.withValues(alpha: 0.25), - icon: Icons.person_rounded, - trailingIcon: _currentCreatorFilter != null ? Icons.close_rounded : Icons.arrow_drop_down_rounded, - label: _currentCreatorFilter == null ? l10n.creator : l10n.filteringBy(_currentCreatorFilterName ?? ''), - onPressed: () { - if (_currentCreatorFilter != null) { - setState(() { - _currentCreatorFilter = null; - _currentCreatorFilterName = null; - }); - _doSearch(); - } else { - showUserInputDialog( - context, - title: l10n.creator, - account: account, - onUserSelected: (user) { - setState(() { - _currentCreatorFilter = user.id; - _currentCreatorFilterName = generateUserFullName( - context, - user.name, - user.displayName, - fetchInstanceNameFromUrl(user.actorId), - ); - }); - _doSearch(); - }, - ); - } - }, - ), - ], - ], - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.only(top: 60), - child: _getSearchBody(context, searchState, account.instance), - ), - ], - ), - ); - }, - ), - ), - ); - } + if (controller.text.isNotEmpty || force || bloc.state.viewingAll) { + final favorites = context.read().state.favorites; + final triggerSearch = force || bloc.state.viewingAll; - Widget _getSearchBody(BuildContext context, SearchState state, String accountInstance) { - final ThemeData theme = Theme.of(context); - final AppLocalizations l10n = AppLocalizations.of(context)!; - final bool tabletMode = context.select((bloc) => bloc.state.tabletMode); - - switch (state.status) { - case SearchStatus.initial: - case SearchStatus.trending: - return AnimatedCrossFade( - duration: const Duration(milliseconds: 250), - crossFadeState: state.trendingCommunities?.isNotEmpty == true && _currentSearchType == MetaSearchType.communities ? CrossFadeState.showSecond : CrossFadeState.showFirst, - firstChild: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Icon(Icons.search_rounded, size: 80, color: theme.dividerColor), - if (widget.communityToSearch == null) ...[ - const SizedBox(height: 30), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32.0), - child: Text( - switch (_currentSearchType) { - MetaSearchType.communities => l10n.searchCommunitiesFederatedWith(accountInstance), - MetaSearchType.users => l10n.searchUsersFederatedWith(accountInstance), - MetaSearchType.comments => l10n.searchCommentsFederatedWith(accountInstance), - MetaSearchType.posts => l10n.searchPostsFederatedWith(accountInstance), - MetaSearchType.instances => l10n.searchInstancesFederatedWith(accountInstance), - _ => '', - }, - textAlign: TextAlign.center, - style: theme.textTheme.titleMedium?.copyWith(color: theme.dividerColor), - ), - ), - ], - if (_controller.text.isEmpty) ...[ - const SizedBox(height: 30), - ThunderActionChip( - label: l10n.viewAll, - onPressed: () => _doSearch(force: true), - ), - ], - ], - ), - secondChild: state.trendingCommunities?.isNotEmpty == true - ? SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (context.read().state.favorites.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 5), - child: Text( - l10n.favorites, - style: theme.textTheme.titleLarge, - ), - ), - ListView.builder( - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemCount: context.read().state.favorites.length, - itemBuilder: (BuildContext context, int index) { - final community = context.read().state.favorites[index]; - return CommunityListEntry(community: community, indicateFavorites: false); - }, - ), - const SizedBox(height: 20), - ], - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 5), - child: Text( - l10n.trendingCommunities, - style: theme.textTheme.titleLarge, - ), - ), - ListView.builder( - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemCount: state.trendingCommunities!.length, - itemBuilder: (BuildContext context, int index) { - final community = state.trendingCommunities![index]; - return CommunityListEntry(community: community); - }, - ), - const SizedBox(height: 5), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ThunderActionChip( - label: l10n.viewAll, - onPressed: () => _doSearch(force: true), - ), - const SizedBox(width: 10), - ThunderActionChip( - trailingIcon: Icons.chevron_right_rounded, - label: l10n.exploreInstance, - onPressed: () => navigateToInstancePage(context, instanceHost: accountInstance, instanceId: null), - ), - ], - ), - const SizedBox(height: 10), - ], - ), - ) - : Container(), - ); - case SearchStatus.loading: - return const Center(child: CircularProgressIndicator()); - case SearchStatus.refreshing: - case SearchStatus.success: - case SearchStatus.done: - case SearchStatus.performingCommentAction: - if (searchIsEmpty(_currentSearchType, searchState: state)) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '${switch (_currentSearchType) { - MetaSearchType.communities => l10n.noCommunitiesFound, - MetaSearchType.users => l10n.noUsersFound, - MetaSearchType.comments => l10n.noCommentsFound, - MetaSearchType.posts => l10n.noPostsFound, - _ => '', - }} ${l10n.trySearchingFor}', - textAlign: TextAlign.center, - style: theme.textTheme.titleMedium?.copyWith(color: theme.dividerColor), - ), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (_currentSearchType != MetaSearchType.communities && widget.communityToSearch == null) ...[ - ThunderActionChip( - label: l10n.communities, - onPressed: () => _setCurrentSearchType(MetaSearchType.communities), - ), - const SizedBox(width: 5), - ], - if (_currentSearchType != MetaSearchType.users && widget.communityToSearch == null) - ThunderActionChip( - label: l10n.users, - onPressed: () => _setCurrentSearchType(MetaSearchType.users), - ), - ], - ), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (_currentSearchType != MetaSearchType.posts) ...[ - ThunderActionChip( - label: l10n.posts, - onPressed: () => _setCurrentSearchType(MetaSearchType.posts), - ), - const SizedBox(width: 5), - ], - if (_currentSearchType != MetaSearchType.comments) - ThunderActionChip( - label: l10n.comments, - onPressed: () => _setCurrentSearchType(MetaSearchType.comments), - ), - ], - ), - ], - ), - ); - } - if (_currentSearchType == MetaSearchType.communities) { - return FadingEdgeScrollView.fromScrollView( - gradientFractionOnEnd: 0, - child: ListView.builder( - controller: _scrollController, - itemCount: state.communities!.length + 1, - itemBuilder: (BuildContext context, int index) { - if (index == state.communities!.length) { - return state.status == SearchStatus.refreshing - ? const Center( - child: Padding( - padding: EdgeInsets.only(bottom: 10), - child: CircularProgressIndicator(), - ), - ) - : Container(); - } else { - final community = state.communities![index]; - return CommunityListEntry(community: community); - } - }, - ), - ); - } else if (_currentSearchType == MetaSearchType.users) { - return FadingEdgeScrollView.fromScrollView( - gradientFractionOnEnd: 0, - child: ListView.builder( - controller: _scrollController, - itemCount: state.users!.length + 1, - itemBuilder: (BuildContext context, int index) { - if (index == state.users!.length) { - return state.status == SearchStatus.refreshing - ? const Center( - child: Padding( - padding: EdgeInsets.only(bottom: 10), - child: CircularProgressIndicator(), - ), - ) - : Container(); - } else { - final user = state.users![index]; - return UserListEntry(user: user); - } - }, - ), - ); - } else if (_currentSearchType == MetaSearchType.comments) { - return FadingEdgeScrollView.fromScrollView( - gradientFractionOnEnd: 0, - child: ListView.builder( - controller: _scrollController, - itemCount: state.comments!.length + 1, - itemBuilder: (BuildContext context, int index) { - if (index == state.comments!.length) { - return state.status == SearchStatus.refreshing - ? const Center( - child: Padding( - padding: EdgeInsets.only(bottom: 10), - child: CircularProgressIndicator(), - ), - ) - : Container(); - } else { - ThunderComment comment = state.comments![index]; - return Column( - children: [ - Divider( - height: 1.0, - thickness: 1.0, - color: ElevationOverlay.applySurfaceTint( - Theme.of(context).colorScheme.surface, - Theme.of(context).colorScheme.surfaceTint, - 10, - ), - ), - CommentListEntry(comment: comment), - ], - ); - } - }, - ), - ); - } else if (_currentSearchType == MetaSearchType.posts) { - return FadingEdgeScrollView.fromScrollView( - gradientFractionOnEnd: 0, - child: CustomScrollView( - controller: _scrollController, - slivers: [ - FeedPostCardList(posts: state.posts ?? [], tabletMode: tabletMode, markPostReadOnScroll: false), - if (state.status == SearchStatus.refreshing) - const SliverToBoxAdapter( - child: Center( - child: Padding( - padding: EdgeInsets.only(bottom: 10), - child: CircularProgressIndicator(), - ), - ), - ), - ], - ), - ); - } else if (_currentSearchType == MetaSearchType.instances) { - return FadingEdgeScrollView.fromScrollView( - gradientFractionOnEnd: 0, - child: ListView.builder( - controller: _scrollController, - itemCount: state.instances!.length, - itemBuilder: (BuildContext context, int index) { - final instanceInfo = state.instances![index]; - return AnimatedCrossFade( - duration: const Duration(milliseconds: 250), - firstChild: InstanceListEntry( - instance: ThunderInstanceInfo( - id: instanceInfo.id, - domain: instanceInfo.domain, - name: fetchInstanceNameFromUrl(instanceInfo.domain)!, - success: instanceInfo.success, - ), - ), - secondChild: InstanceListEntry(instance: instanceInfo), - // If the instance metadata is not fully populated, show one widget, otherwise show the other. - // This should allow the metadata to essentially "fade in". - crossFadeState: instanceInfo.isMetadataPopulated() ? CrossFadeState.showSecond : CrossFadeState.showFirst, - ); - }, - ), - ); - } else { - return Container(); - } - case SearchStatus.empty: - return Center(child: Text(l10n.empty)); - case SearchStatus.failure: - return ErrorMessage( - message: state.errorMessage, - actions: [ - ( - text: l10n.retry, - action: _doSearch, - loading: false, - ), - ], - ); + bloc.add(SearchStarted(query: controller.text, force: triggerSearch, favoriteCommunities: favorites)); + } else { + bloc.add(const SearchReset()); } } - void showSortBottomSheet(BuildContext context) { + void showSortPicker() { final l10n = GlobalContext.l10n; final feedBloc = context.read(); + final searchBloc = context.read(); + + final prefs = UserPreferences.instance.preferences; showModalBottomSheet( context: context, @@ -794,61 +153,90 @@ class _SearchPageState extends State with AutomaticKeepAliveClientMi account: feedBloc.account, title: l10n.sortOptions, onSelect: (selected) async { - setState(() { - postSortType = selected.payload; - postSortTypeIcon = selected.icon; - postSortTypeLabel = selected.label; - }); - - UserPreferences.instance.preferences.setString("search_default_sort_type", selected.payload.name); - - _doSearch(); + searchBloc.add(SearchFiltersUpdated(sortType: selected.payload, sortTypeIcon: selected.icon, sortTypeLabel: selected.label)); + prefs.setString("search_default_sort_type", selected.payload.name); + search(); }, - previouslySelected: postSortType, + previouslySelected: searchBloc.state.postSortType ?? PostSortType.active, ), ); } - MetaSearchType _getSearchTypeToUse() { - if (_currentSearchType == MetaSearchType.posts && _searchByUrl) { - return MetaSearchType.url; + void setSearchType(MetaSearchType searchType) { + final bloc = context.read(); + + // Auto-detect URL mode for post searches + if (searchType == MetaSearchType.posts && Uri.tryParse(controller.text)?.isAbsolute == true) { + bloc.add(const SearchFiltersUpdated(searchByUrl: true)); } - return _currentSearchType; + + bloc.add(SearchFiltersUpdated(searchType: searchType)); + search(); } - /// Performs a search with the current parameters. - /// Does not search when the query field is empty, unless [force] is `true`. - void _doSearch({bool force = false}) { - final SearchBloc searchBloc = context.read(); - - if (_controller.text.isNotEmpty || force || searchBloc.state.viewingAll) { - searchBloc.add(StartSearchEvent( - query: _controller.text, - postSortType: postSortType, - feedListType: _currentFeedType, - searchType: _getSearchTypeToUse(), - communityId: widget.communityToSearch?.id ?? _currentCommunityFilter, - creatorId: _currentCreatorFilter, - favoriteCommunities: context.read().state.favorites, - force: force || searchBloc.state.viewingAll, - )); - } else { - context.read().add(ResetSearch()); + List> getSearchOptions(Account account) { + final l10n = GlobalContext.l10n; + + List> options = [ + ListPickerItem(label: l10n.communities, payload: MetaSearchType.communities, icon: Icons.people_rounded), + ListPickerItem(label: l10n.users, payload: MetaSearchType.users, icon: Icons.person_rounded), + ListPickerItem(label: l10n.posts, payload: MetaSearchType.posts, icon: Icons.wysiwyg_rounded), + ListPickerItem(label: l10n.comments, payload: MetaSearchType.comments, icon: Icons.chat_rounded), + ListPickerItem(label: l10n.instance(2), payload: MetaSearchType.instances, icon: Icons.language), + ]; + + // Only keep post/comment for community search + if (widget.community != null) { + options = options.where((o) => o.payload == MetaSearchType.posts || o.payload == MetaSearchType.comments).toList(); } - } - void _setCurrentSearchType(MetaSearchType newCurrentSearchType) { - final AppLocalizations l10n = AppLocalizations.of(context)!; + return options; + } - setState(() { - _currentSearchType = newCurrentSearchType; + @override + Widget build(BuildContext context) { + super.build(context); - if (_currentSearchType == MetaSearchType.posts && Uri.tryParse(_controller.text)?.isAbsolute == true) { - _searchByUrl = true; - _searchUrlLabel = l10n.url; - } - }); + final l10n = GlobalContext.l10n; + final account = context.select((bloc) => bloc.state.account); - _doSearch(); + return BlocListener( + listenWhen: (previous, current) => previous.focusSearchId != current.focusSearchId, + listener: (context, state) { + if (state.focusSearchId > previousFocusSearchId) { + searchTextFieldFocus.requestFocus(); + previousFocusSearchId = state.focusSearchId; + } + }, + child: Scaffold( + appBar: SearchPageAppBar( + controller: controller, + focusNode: searchTextFieldFocus, + hintText: l10n.searchInstance(widget.community?.name ?? account.instance), + onChanged: (value) => debounce(const Duration(milliseconds: 300), onSearchFieldChanged, [value]), + onClear: onClearSearch, + ), + body: Stack( + children: [ + SearchFiltersRow( + searchOptions: getSearchOptions(account), + community: widget.community, + account: account, + onSearch: search, + onShowSortPicker: showSortPicker, + ), + SearchBody( + scrollController: scrollController, + communityToSearch: widget.community, + accountInstance: account.instance, + isQueryEmpty: controller.text.isEmpty, + onSearch: search, + onViewAll: () => search(force: true), + onSetSearchType: setSearchType, + ), + ], + ), + ), + ); } } diff --git a/lib/src/features/search/presentation/widgets/search_body.dart b/lib/src/features/search/presentation/widgets/search_body.dart new file mode 100644 index 000000000..6ea3f7831 --- /dev/null +++ b/lib/src/features/search/presentation/widgets/search_body.dart @@ -0,0 +1,391 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/l10n/generated/app_localizations.dart'; +import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/app/utils/navigation.dart'; +import 'package:thunder/src/core/enums/meta_search_type.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/features/search/search.dart'; +import 'package:thunder/src/features/search/presentation/widgets/search_comments_results.dart'; +import 'package:thunder/src/features/search/presentation/widgets/search_communities_results.dart'; +import 'package:thunder/src/features/search/presentation/widgets/search_instances_results.dart'; +import 'package:thunder/src/features/search/presentation/widgets/search_posts_results.dart'; +import 'package:thunder/src/features/search/presentation/widgets/search_users_results.dart'; +import 'package:thunder/src/shared/error_message.dart'; +import 'package:thunder/src/shared/widgets/chips/thunder_action_chip.dart'; + +/// The main body content of the search page showing results based on search state. +class SearchBody extends StatelessWidget { + /// The scroll controller for infinite scrolling. + final ScrollController scrollController; + + /// The community to search within (if any). + final ThunderCommunity? communityToSearch; + + /// The account instance host for display. + final String accountInstance; + + /// Whether the search query is empty. + final bool isQueryEmpty; + + /// Called to trigger a search. + final VoidCallback onSearch; + + /// Called to force a search (view all). + final VoidCallback onViewAll; + + /// Called to change the search type. + final ValueChanged onSetSearchType; + + const SearchBody({ + super.key, + required this.scrollController, + required this.communityToSearch, + required this.accountInstance, + required this.isQueryEmpty, + required this.onSearch, + required this.onViewAll, + required this.onSetSearchType, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 60), + child: BlocBuilder( + buildWhen: (previous, current) => previous.status != current.status || previous.searchType != current.searchType || previous.trendingCommunities != current.trendingCommunities, + builder: (context, state) => _buildBody(context, state), + ), + ); + } + + Widget _buildBody(BuildContext context, SearchState state) { + final l10n = AppLocalizations.of(context)!; + + switch (state.status) { + case SearchStatus.initial: + case SearchStatus.trending: + return _SearchInitialView( + communityToSearch: communityToSearch, + accountInstance: accountInstance, + isQueryEmpty: isQueryEmpty, + onViewAll: onViewAll, + trendingCommunities: state.trendingCommunities, + searchType: state.searchType, + ); + case SearchStatus.loading: + return const Center(child: CircularProgressIndicator()); + case SearchStatus.refreshing: + case SearchStatus.success: + case SearchStatus.done: + case SearchStatus.performingCommentAction: + return _SearchResultsView( + scrollController: scrollController, + communityToSearch: communityToSearch, + onSetSearchType: onSetSearchType, + state: state, + ); + case SearchStatus.empty: + return Center(child: Text(l10n.empty)); + case SearchStatus.failure: + return _SearchErrorView( + errorMessage: state.message, + onRetry: onSearch, + ); + } + } +} + +/// Widget that displays the initial view when no search has been performed. +class _SearchInitialView extends StatelessWidget { + final ThunderCommunity? communityToSearch; + final String accountInstance; + final bool isQueryEmpty; + final VoidCallback onViewAll; + final List? trendingCommunities; + final MetaSearchType searchType; + + const _SearchInitialView({ + required this.communityToSearch, + required this.accountInstance, + required this.isQueryEmpty, + required this.onViewAll, + required this.trendingCommunities, + required this.searchType, + }); + + @override + Widget build(BuildContext context) { + final showTrending = trendingCommunities?.isNotEmpty == true && searchType == MetaSearchType.communities; + + return AnimatedCrossFade( + duration: const Duration(milliseconds: 250), + crossFadeState: showTrending ? CrossFadeState.showSecond : CrossFadeState.showFirst, + firstChild: _SearchEmptyPrompt( + communityToSearch: communityToSearch, + accountInstance: accountInstance, + isQueryEmpty: isQueryEmpty, + onViewAll: onViewAll, + searchType: searchType, + ), + secondChild: showTrending + ? _SearchTrendingView( + trendingCommunities: trendingCommunities!, + accountInstance: accountInstance, + onViewAll: onViewAll, + ) + : const SizedBox.shrink(), + ); + } +} + +/// Widget that displays the empty search prompt. +class _SearchEmptyPrompt extends StatelessWidget { + final ThunderCommunity? communityToSearch; + final String accountInstance; + final bool isQueryEmpty; + final VoidCallback onViewAll; + final MetaSearchType searchType; + + const _SearchEmptyPrompt({ + required this.communityToSearch, + required this.accountInstance, + required this.isQueryEmpty, + required this.onViewAll, + required this.searchType, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = GlobalContext.l10n; + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon(Icons.search_rounded, size: 80, color: theme.dividerColor), + if (communityToSearch == null) ...[ + const SizedBox(height: 30), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: Text( + switch (searchType) { + MetaSearchType.communities => l10n.searchCommunitiesFederatedWith(accountInstance), + MetaSearchType.users => l10n.searchUsersFederatedWith(accountInstance), + MetaSearchType.comments => l10n.searchCommentsFederatedWith(accountInstance), + MetaSearchType.posts => l10n.searchPostsFederatedWith(accountInstance), + MetaSearchType.instances => l10n.searchInstancesFederatedWith(accountInstance), + _ => '', + }, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith(color: theme.dividerColor), + ), + ), + ], + if (isQueryEmpty) ...[ + const SizedBox(height: 30), + ThunderActionChip( + label: l10n.viewAll, + onPressed: onViewAll, + ), + ], + ], + ); + } +} + +/// Widget that displays the trending communities view. +class _SearchTrendingView extends StatelessWidget { + final List trendingCommunities; + final String accountInstance; + final VoidCallback onViewAll; + + const _SearchTrendingView({ + required this.trendingCommunities, + required this.accountInstance, + required this.onViewAll, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = GlobalContext.l10n; + + return BlocSelector>( + selector: (state) => state.favorites, + builder: (context, favorites) { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (favorites.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 5), + child: Text(l10n.favorites, style: theme.textTheme.titleLarge), + ), + ListView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: favorites.length, + itemBuilder: (context, index) => CommunityListEntry(community: favorites[index], indicateFavorites: false), + ), + const SizedBox(height: 20), + ], + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 5), + child: Text(l10n.trendingCommunities, style: theme.textTheme.titleLarge), + ), + ListView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: trendingCommunities.length, + itemBuilder: (context, index) => CommunityListEntry(community: trendingCommunities[index]), + ), + const SizedBox(height: 5), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ThunderActionChip(label: l10n.viewAll, onPressed: onViewAll), + const SizedBox(width: 10), + ThunderActionChip( + trailingIcon: Icons.chevron_right_rounded, + label: l10n.exploreInstance, + onPressed: () => navigateToInstancePage(context, instanceHost: accountInstance, instanceId: null), + ), + ], + ), + const SizedBox(height: 10), + ], + ), + ); + }, + ); + } +} + +/// Widget that displays search results based on the current search type. +class _SearchResultsView extends StatelessWidget { + final ScrollController scrollController; + final ThunderCommunity? communityToSearch; + final ValueChanged onSetSearchType; + final SearchState state; + + const _SearchResultsView({ + required this.scrollController, + required this.communityToSearch, + required this.onSetSearchType, + required this.state, + }); + + @override + Widget build(BuildContext context) { + if (searchIsEmpty(state.searchType, searchState: state)) { + return _SearchNoResultsView( + searchType: state.searchType, + communityToSearch: communityToSearch, + onSetSearchType: onSetSearchType, + ); + } + + return BlocSelector( + selector: (state) => state.account, + builder: (context, account) { + return switch (state.searchType) { + MetaSearchType.communities => SearchCommunitiesResults(scrollController: scrollController), + MetaSearchType.users => SearchUsersResults(scrollController: scrollController), + MetaSearchType.comments => SearchCommentsResults(scrollController: scrollController), + MetaSearchType.posts => SearchPostsResults(scrollController: scrollController, account: account), + MetaSearchType.instances => SearchInstancesResults(scrollController: scrollController), + _ => const SizedBox.shrink(), + }; + }, + ); + } +} + +/// Widget that displays when no search results are found. +class _SearchNoResultsView extends StatelessWidget { + final MetaSearchType searchType; + final ThunderCommunity? communityToSearch; + final ValueChanged onSetSearchType; + + const _SearchNoResultsView({ + required this.searchType, + required this.communityToSearch, + required this.onSetSearchType, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = GlobalContext.l10n; + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${switch (searchType) { + MetaSearchType.communities => l10n.noCommunitiesFound, + MetaSearchType.users => l10n.noUsersFound, + MetaSearchType.comments => l10n.noCommentsFound, + MetaSearchType.posts => l10n.noPostsFound, + _ => '', + }} ${l10n.trySearchingFor}', + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith(color: theme.dividerColor), + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (searchType != MetaSearchType.communities && communityToSearch == null) ...[ + ThunderActionChip(label: l10n.communities, onPressed: () => onSetSearchType(MetaSearchType.communities)), + const SizedBox(width: 5), + ], + if (searchType != MetaSearchType.users && communityToSearch == null) ThunderActionChip(label: l10n.users, onPressed: () => onSetSearchType(MetaSearchType.users)), + ], + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (searchType != MetaSearchType.posts) ...[ + ThunderActionChip(label: l10n.posts, onPressed: () => onSetSearchType(MetaSearchType.posts)), + const SizedBox(width: 5), + ], + if (searchType != MetaSearchType.comments) ThunderActionChip(label: l10n.comments, onPressed: () => onSetSearchType(MetaSearchType.comments)), + ], + ), + ], + ), + ); + } +} + +/// Widget that displays an error message with retry option. +class _SearchErrorView extends StatelessWidget { + final String? errorMessage; + final VoidCallback onRetry; + + const _SearchErrorView({ + required this.errorMessage, + required this.onRetry, + }); + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + + return ErrorMessage( + message: errorMessage, + actions: [ + (text: l10n.retry, action: onRetry, loading: false), + ], + ); + } +} diff --git a/lib/src/features/search/presentation/widgets/search_comments_results.dart b/lib/src/features/search/presentation/widgets/search_comments_results.dart new file mode 100644 index 000000000..aa0cbb5f0 --- /dev/null +++ b/lib/src/features/search/presentation/widgets/search_comments_results.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/features/comment/comment.dart'; +import 'package:thunder/src/features/search/search.dart'; + +/// Displays search results for comments. +class SearchCommentsResults extends StatelessWidget { + /// The scroll controller for infinite scrolling. + final ScrollController scrollController; + + const SearchCommentsResults({super.key, required this.scrollController}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return BlocSelector?, SearchStatus)>( + selector: (state) => (state.comments, state.status), + builder: (context, data) { + final (comments, status) = data; + if (comments == null) return const SizedBox.shrink(); + + return ListView.builder( + controller: scrollController, + itemCount: comments.length + 1, + itemBuilder: (context, index) { + if (index == comments.length) { + return status == SearchStatus.refreshing + ? const Center( + child: Padding( + padding: EdgeInsets.only(bottom: 10), + child: CircularProgressIndicator(), + ), + ) + : const SizedBox.shrink(); + } + return Column( + children: [ + Divider( + height: 1.0, + thickness: 1.0, + color: ElevationOverlay.applySurfaceTint( + theme.colorScheme.surface, + theme.colorScheme.surfaceTint, + 10, + ), + ), + CommentListEntry(comment: comments[index]), + ], + ); + }, + ); + }, + ); + } +} diff --git a/lib/src/features/search/presentation/widgets/search_communities_results.dart b/lib/src/features/search/presentation/widgets/search_communities_results.dart new file mode 100644 index 000000000..4899acd0c --- /dev/null +++ b/lib/src/features/search/presentation/widgets/search_communities_results.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/features/search/search.dart'; + +/// Displays search results for communities. +class SearchCommunitiesResults extends StatelessWidget { + /// The scroll controller for infinite scrolling. + final ScrollController scrollController; + + const SearchCommunitiesResults({super.key, required this.scrollController}); + + @override + Widget build(BuildContext context) { + return BlocSelector?, SearchStatus)>( + selector: (state) => (state.communities, state.status), + builder: (context, data) { + final (communities, status) = data; + if (communities == null) return const SizedBox.shrink(); + + return ListView.builder( + controller: scrollController, + itemCount: communities.length + 1, + itemBuilder: (context, index) { + if (index == communities.length) { + return status == SearchStatus.refreshing + ? const Center( + child: Padding( + padding: EdgeInsets.only(bottom: 10), + child: CircularProgressIndicator(), + ), + ) + : const SizedBox.shrink(); + } + return CommunityListEntry(community: communities[index]); + }, + ); + }, + ); + } +} diff --git a/lib/src/features/search/presentation/widgets/search_filters_row.dart b/lib/src/features/search/presentation/widgets/search_filters_row.dart new file mode 100644 index 000000000..3ce3333fc --- /dev/null +++ b/lib/src/features/search/presentation/widgets/search_filters_row.dart @@ -0,0 +1,408 @@ +import 'package:flutter/material.dart'; + +import 'package:fading_edge_scrollview/fading_edge_scrollview.dart'; +import 'package:flex_color_scheme/flex_color_scheme.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/core/enums/enums.dart'; +import 'package:thunder/src/core/enums/full_name.dart'; +import 'package:thunder/src/core/enums/meta_search_type.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/community/community.dart'; +import 'package:thunder/src/features/search/search.dart'; +import 'package:thunder/src/shared/input_dialogs.dart'; +import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; +import 'package:thunder/src/shared/utils/instance.dart'; +import 'package:thunder/src/shared/widgets/chips/thunder_action_chip.dart'; + +/// The horizontal filter chips row for search options. +class SearchFiltersRow extends StatefulWidget { + /// Available search type options. + final List> searchOptions; + + /// Limits the search to a specific community. + final ThunderCommunity? community; + + /// The current account. + final Account account; + + /// Called to trigger a search after filter changes. + final VoidCallback onSearch; + + /// Called to show the sort picker. + final VoidCallback onShowSortPicker; + + const SearchFiltersRow({ + super.key, + required this.searchOptions, + required this.community, + required this.account, + required this.onSearch, + required this.onShowSortPicker, + }); + + @override + State createState() => _SearchFiltersRowState(); +} + +class _SearchFiltersRowState extends State { + final ScrollController _scrollController = ScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = GlobalContext.l10n; + + return BlocBuilder( + buildWhen: (previous, current) => previous.searchType != current.searchType || previous.searchByUrl != current.searchByUrl || previous.viewingAll != current.viewingAll, + builder: (context, state) { + return Padding( + padding: const EdgeInsets.only(left: 15.0, top: 10.0, right: 15.0), + child: FadingEdgeScrollView.fromSingleChildScrollView( + gradientFractionOnStart: 0.1, + gradientFractionOnEnd: 0.1, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: _scrollController, + child: Row( + children: [ + // "Viewing All" indicator chip + if (state.viewingAll) ...[ + ThunderActionChip( + backgroundColor: theme.colorScheme.primaryContainer.withValues(alpha: 0.25), + trailingIcon: Icons.close_rounded, + label: l10n.viewingAll, + onPressed: () => context.read().add(const SearchReset()), + ), + const SizedBox(width: 10), + ], + + // Search type chip + _SearchTypeChip( + searchOptions: widget.searchOptions, + onSearch: widget.onSearch, + ), + const SizedBox(width: 10), + + // URL/Text toggle for posts + if (state.searchType == MetaSearchType.posts) ...[ + _UrlTextChip(onSearch: widget.onSearch), + const SizedBox(width: 10), + ], + + // Sort, feed type, and filter chips (except for instances) + if (state.searchType != MetaSearchType.instances) ...[ + _SortChip(onShowSortPicker: widget.onShowSortPicker), + if (widget.community == null) ...[ + const SizedBox(width: 10), + _FeedTypeChip(onSearch: widget.onSearch), + const SizedBox(width: 10), + _CommunityFilterChip( + account: widget.account, + onSearch: widget.onSearch, + ), + ], + const SizedBox(width: 10), + _CreatorFilterChip( + account: widget.account, + onSearch: widget.onSearch, + ), + ], + ], + ), + ), + ), + ); + }, + ); + } +} + +/// Chip widget for selecting search type. +class _SearchTypeChip extends StatelessWidget { + final List> searchOptions; + final VoidCallback onSearch; + + const _SearchTypeChip({ + required this.searchOptions, + required this.onSearch, + }); + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + + return BlocSelector( + selector: (state) => state.searchType, + builder: (context, searchType) { + return ThunderActionChip( + trailingIcon: Icons.arrow_drop_down_rounded, + label: searchType.name.capitalize, + onPressed: () { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (ctx) => BottomSheetListPicker( + title: l10n.selectSearchType, + items: searchOptions, + onSelect: (value) async { + context.read().add(SearchFiltersUpdated(searchType: value.payload)); + onSearch(); + }, + previouslySelected: searchType, + ), + ); + }, + ); + }, + ); + } +} + +/// Chip widget for toggling between URL and text search for posts. +class _UrlTextChip extends StatelessWidget { + final VoidCallback onSearch; + + const _UrlTextChip({required this.onSearch}); + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + + return BlocSelector( + selector: (state) => state.searchByUrl, + builder: (context, searchByUrl) { + return ThunderActionChip( + icon: Icons.link_rounded, + trailingIcon: Icons.arrow_drop_down_rounded, + label: searchByUrl ? l10n.url : l10n.text, + onPressed: () { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (ctx) => BottomSheetListPicker( + title: l10n.searchPostSearchType, + items: [ + ListPickerItem(label: l10n.searchByText, payload: 'text', icon: Icons.wysiwyg_rounded), + ListPickerItem(label: l10n.searchByUrl, payload: 'url', icon: Icons.link_rounded), + ], + onSelect: (value) async { + context.read().add(SearchFiltersUpdated(searchByUrl: value.payload == 'url')); + onSearch(); + }, + previouslySelected: searchByUrl ? 'url' : 'text', + ), + ); + }, + ); + }, + ); + } +} + +/// Chip widget for selecting sort type. +class _SortChip extends StatelessWidget { + final VoidCallback onShowSortPicker; + + const _SortChip({required this.onShowSortPicker}); + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + + return BlocSelector( + selector: (state) => (state.sortTypeIcon, state.sortTypeLabel), + builder: (context, data) { + final (sortTypeIcon, sortTypeLabel) = data; + + return ThunderActionChip( + icon: sortTypeIcon, + trailingIcon: Icons.arrow_drop_down_rounded, + label: sortTypeLabel ?? l10n.sortBy, + onPressed: onShowSortPicker, + ); + }, + ); + } +} + +/// Chip widget for selecting feed type (All, Local, Subscribed). +class _FeedTypeChip extends StatelessWidget { + final VoidCallback onSearch; + + const _FeedTypeChip({required this.onSearch}); + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + + return BlocSelector( + selector: (state) => state.feedListType, + builder: (context, feedListType) { + return ThunderActionChip( + icon: _getFeedTypeIcon(feedListType), + trailingIcon: Icons.arrow_drop_down_rounded, + label: _getFeedTypeLabel(feedListType, l10n), + onPressed: () { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (ctx) => BottomSheetListPicker( + title: l10n.selectFeedType, + items: [ + ListPickerItem(label: l10n.subscribed, payload: FeedListType.subscribed, icon: Icons.view_list_rounded), + ListPickerItem(label: l10n.local, payload: FeedListType.local, icon: Icons.home_rounded), + ListPickerItem(label: l10n.all, payload: FeedListType.all, icon: Icons.grid_view_rounded), + ], + onSelect: (value) async { + context.read().add(SearchFiltersUpdated(feedListType: value.payload)); + onSearch(); + }, + previouslySelected: feedListType, + ), + ); + }, + ); + }, + ); + } + + IconData _getFeedTypeIcon(FeedListType feedListType) { + return switch (feedListType) { + FeedListType.subscribed => Icons.view_list_rounded, + FeedListType.local => Icons.home_rounded, + FeedListType.all => Icons.grid_view_rounded, + _ => Icons.grid_view_rounded, + }; + } + + String _getFeedTypeLabel(FeedListType feedListType, dynamic l10n) { + return switch (feedListType) { + FeedListType.subscribed => l10n.subscribed, + FeedListType.local => l10n.local, + FeedListType.all => l10n.all, + _ => l10n.feed, + }; + } +} + +/// Chip widget for filtering by community. +class _CommunityFilterChip extends StatelessWidget { + final Account account; + final VoidCallback onSearch; + + const _CommunityFilterChip({ + required this.account, + required this.onSearch, + }); + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + final theme = Theme.of(context); + + return BlocSelector( + selector: (state) => (state.communityFilter, state.communityFilterName), + builder: (context, data) { + final (communityFilter, communityFilterName) = data; + + return ThunderActionChip( + backgroundColor: communityFilter == null ? null : theme.colorScheme.primaryContainer.withValues(alpha: 0.25), + icon: Icons.people_rounded, + trailingIcon: communityFilter != null ? Icons.close_rounded : Icons.arrow_drop_down_rounded, + label: communityFilter == null ? l10n.community : l10n.filteringBy(communityFilterName ?? ''), + onPressed: () { + if (communityFilter != null) { + context.read().add(const SearchFiltersUpdated(clearCommunityFilter: true)); + onSearch(); + } else { + showCommunityInputDialog( + context, + title: l10n.community, + account: account, + onCommunitySelected: (ThunderCommunity community) { + context.read().add( + SearchFiltersUpdated( + communityFilter: community.id, + communityFilterName: generateCommunityFullName( + context, + community.name, + community.title, + fetchInstanceNameFromUrl(community.actorId), + ), + ), + ); + onSearch(); + }, + ); + } + }, + ); + }, + ); + } +} + +/// Chip widget for filtering by creator. +class _CreatorFilterChip extends StatelessWidget { + final Account account; + final VoidCallback onSearch; + + const _CreatorFilterChip({ + required this.account, + required this.onSearch, + }); + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + final theme = Theme.of(context); + + return BlocSelector( + selector: (state) => (state.creatorFilter, state.creatorFilterName), + builder: (context, data) { + final (creatorFilter, creatorFilterName) = data; + + return ThunderActionChip( + backgroundColor: creatorFilter == null ? null : theme.colorScheme.primaryContainer.withValues(alpha: 0.25), + icon: Icons.person_rounded, + trailingIcon: creatorFilter != null ? Icons.close_rounded : Icons.arrow_drop_down_rounded, + label: creatorFilter == null ? l10n.creator : l10n.filteringBy(creatorFilterName ?? ''), + onPressed: () { + if (creatorFilter != null) { + context.read().add(const SearchFiltersUpdated(clearCreatorFilter: true)); + onSearch(); + } else { + showUserInputDialog( + context, + title: l10n.creator, + account: account, + onUserSelected: (user) { + context.read().add( + SearchFiltersUpdated( + creatorFilter: user.id, + creatorFilterName: generateUserFullName( + context, + user.name, + user.displayName, + fetchInstanceNameFromUrl(user.actorId), + ), + ), + ); + onSearch(); + }, + ); + } + }, + ); + }, + ); + } +} diff --git a/lib/src/features/search/presentation/widgets/search_instances_results.dart b/lib/src/features/search/presentation/widgets/search_instances_results.dart new file mode 100644 index 000000000..a6d0c2e9b --- /dev/null +++ b/lib/src/features/search/presentation/widgets/search_instances_results.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/core/models/models.dart'; +import 'package:thunder/src/features/instance/instance.dart'; +import 'package:thunder/src/features/search/search.dart'; +import 'package:thunder/src/shared/utils/instance.dart'; + +/// Displays search results for instances. +class SearchInstancesResults extends StatelessWidget { + /// The scroll controller for infinite scrolling. + final ScrollController scrollController; + + const SearchInstancesResults({super.key, required this.scrollController}); + + @override + Widget build(BuildContext context) { + return BlocSelector?>( + selector: (state) => state.instances, + builder: (context, instances) { + if (instances == null) return const SizedBox.shrink(); + + return ListView.builder( + controller: scrollController, + itemCount: instances.length, + itemBuilder: (context, index) { + final instanceInfo = instances[index]; + return AnimatedCrossFade( + duration: const Duration(milliseconds: 250), + firstChild: InstanceListEntry( + instance: ThunderInstanceInfo( + id: instanceInfo.id, + domain: instanceInfo.domain, + name: fetchInstanceNameFromUrl(instanceInfo.domain)!, + success: instanceInfo.success, + ), + ), + secondChild: InstanceListEntry(instance: instanceInfo), + crossFadeState: instanceInfo.isMetadataPopulated() ? CrossFadeState.showSecond : CrossFadeState.showFirst, + ); + }, + ); + }, + ); + } +} diff --git a/lib/src/features/search/presentation/widgets/search_page_app_bar.dart b/lib/src/features/search/presentation/widgets/search_page_app_bar.dart new file mode 100644 index 000000000..c3e96e08a --- /dev/null +++ b/lib/src/features/search/presentation/widgets/search_page_app_bar.dart @@ -0,0 +1,72 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:thunder/src/app/utils/global_context.dart'; +import 'package:thunder/src/shared/utils/constants.dart'; + +/// The app bar for the search page containing the search bar. +class SearchPageAppBar extends StatelessWidget implements PreferredSizeWidget { + /// The text controller for the search field. + final TextEditingController controller; + + /// The focus node for the search field. + final FocusNode focusNode; + + /// The hint text to display in the search field. + final String hintText; + + /// Called when the search text changes. + final ValueChanged onChanged; + + /// Called when the clear button is pressed. + final VoidCallback onClear; + + const SearchPageAppBar({ + super.key, + required this.controller, + required this.focusNode, + required this.hintText, + required this.onChanged, + required this.onClear, + }); + + @override + Size get preferredSize => const Size.fromHeight(APP_BAR_HEIGHT); + + @override + Widget build(BuildContext context) { + final l10n = GlobalContext.l10n; + + return AppBar( + toolbarHeight: APP_BAR_HEIGHT, + scrolledUnderElevation: 0.0, + title: ListenableBuilder( + listenable: controller, + builder: (context, _) => SearchBar( + elevation: WidgetStateProperty.all(0.0), + controller: controller, + focusNode: focusNode, + hintText: hintText, + keyboardType: (!kIsWeb && Platform.isIOS) ? TextInputType.text : TextInputType.url, + onChanged: onChanged, + onTap: () => HapticFeedback.selectionClick(), + leading: const Padding( + padding: EdgeInsets.only(left: 8.0), + child: Icon(Icons.search_rounded), + ), + trailing: controller.text.isNotEmpty + ? [ + IconButton( + icon: Icon(Icons.close, semanticLabel: l10n.clearSearch), + onPressed: onClear, + ), + ] + : null, + ), + ), + ); + } +} diff --git a/lib/src/features/search/presentation/widgets/search_posts_results.dart b/lib/src/features/search/presentation/widgets/search_posts_results.dart new file mode 100644 index 000000000..2718a00ed --- /dev/null +++ b/lib/src/features/search/presentation/widgets/search_posts_results.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/app/bloc/thunder_bloc.dart'; +import 'package:thunder/src/features/account/account.dart'; +import 'package:thunder/src/features/feed/feed.dart'; +import 'package:thunder/src/features/post/post.dart'; +import 'package:thunder/src/features/search/search.dart'; + +/// Displays search results for posts. +class SearchPostsResults extends StatefulWidget { + /// The scroll controller for infinite scrolling. + final ScrollController scrollController; + + /// The current account. + final Account account; + + const SearchPostsResults({super.key, required this.scrollController, required this.account}); + + @override + State createState() => _SearchPostsResultsState(); +} + +class _SearchPostsResultsState extends State { + late final FeedBloc _feedBloc; + + @override + void initState() { + super.initState(); + _feedBloc = FeedBloc(account: widget.account); + + // Initialize with current posts + final posts = context.read().state.posts; + if (posts != null && posts.isNotEmpty) { + _feedBloc.add(PopulatePostsEvent(posts)); + } + } + + @override + void dispose() { + _feedBloc.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final tabletMode = context.select((bloc) => bloc.state.tabletMode); + + return BlocProvider.value( + value: _feedBloc, + child: BlocListener( + listenWhen: (previous, current) => previous.posts != current.posts, + listener: (context, state) { + _feedBloc.add(PopulatePostsEvent(state.posts ?? [])); + }, + child: BlocSelector( + selector: (state) => state.status, + builder: (context, status) { + // Read posts from FeedBloc - this ensures post actions are reflected in the UI + return BlocSelector>( + selector: (state) => state.posts, + builder: (context, posts) { + return CustomScrollView( + controller: widget.scrollController, + slivers: [ + FeedPostCardList( + posts: posts, + tabletMode: tabletMode, + markPostReadOnScroll: false, + ), + if (status == SearchStatus.refreshing) + const SliverToBoxAdapter( + child: Center( + child: Padding( + padding: EdgeInsets.only(bottom: 10.0), + child: CircularProgressIndicator(), + ), + ), + ), + ], + ); + }, + ); + }, + ), + ), + ); + } +} diff --git a/lib/src/features/search/presentation/widgets/search_users_results.dart b/lib/src/features/search/presentation/widgets/search_users_results.dart new file mode 100644 index 000000000..602d398b0 --- /dev/null +++ b/lib/src/features/search/presentation/widgets/search_users_results.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:thunder/src/features/search/search.dart'; +import 'package:thunder/src/features/user/user.dart'; + +/// Displays search results for users. +class SearchUsersResults extends StatelessWidget { + /// The scroll controller for infinite scrolling. + final ScrollController scrollController; + + const SearchUsersResults({super.key, required this.scrollController}); + + @override + Widget build(BuildContext context) { + return BlocSelector?, SearchStatus)>( + selector: (state) => (state.users, state.status), + builder: (context, data) { + final (users, status) = data; + if (users == null) return const SizedBox.shrink(); + + return ListView.builder( + controller: scrollController, + itemCount: users.length + 1, + itemBuilder: (context, index) { + if (index == users.length) { + return status == SearchStatus.refreshing + ? const Center( + child: Padding( + padding: EdgeInsets.only(bottom: 10.0), + child: CircularProgressIndicator(), + ), + ) + : const SizedBox.shrink(); + } + + return UserListEntry(user: users[index]); + }, + ); + }, + ); + } +} From bc7bb3b282f65b4f762396ce74767c3c239fdf5c Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su Date: Tue, 13 Jan 2026 16:44:34 -0800 Subject: [PATCH 2/3] feat: limit search sort types to valid entries --- lib/src/core/enums/search_sort_type.dart | 26 ++ lib/src/core/network/lemmy_api.dart | 3 +- lib/src/core/network/piefed_api.dart | 3 +- .../community_header_actions.dart | 3 +- .../feed/presentation/widgets/feed_fab.dart | 3 +- .../widgets/feed_page_app_bar.dart | 7 +- .../inbox/presentation/pages/inbox_page.dart | 4 +- .../bloc/instance_page_event.dart | 10 +- .../presentation/pages/instance_page.dart | 40 +-- .../widgets/instance_page_app_bar.dart | 12 +- .../presentation/widgets/instance_tabs.dart | 34 +-- .../presentation/pages/create_post_page.dart | 4 +- .../widgets/post_page_app_bar.dart | 4 +- .../presentation/widgets/post_page_fab.dart | 5 +- .../data/repositories/search_repository.dart | 6 +- .../search/presentation/bloc/search_bloc.dart | 8 +- .../presentation/bloc/search_event.dart | 2 +- .../presentation/bloc/search_state.dart | 10 +- .../presentation/pages/search_page.dart | 10 +- .../pages/general_settings_page.dart | 5 +- .../pages/user_settings_page.dart | 3 +- .../user_header/user_header_actions.dart | 3 +- lib/src/shared/comment_sort_picker.dart | 112 -------- lib/src/shared/input_dialogs.dart | 4 +- lib/src/shared/sort_picker.dart | 262 ++++++++++++++++-- lib/src/shared/utils/constants.dart | 3 +- 26 files changed, 369 insertions(+), 217 deletions(-) create mode 100644 lib/src/core/enums/search_sort_type.dart delete mode 100644 lib/src/shared/comment_sort_picker.dart diff --git a/lib/src/core/enums/search_sort_type.dart b/lib/src/core/enums/search_sort_type.dart new file mode 100644 index 000000000..a1e496050 --- /dev/null +++ b/lib/src/core/enums/search_sort_type.dart @@ -0,0 +1,26 @@ +import 'package:thunder/src/core/enums/threadiverse_platform.dart'; + +enum SearchSortType { + new_('New'), + old('Old'), + controversial('Controversial'), + topHour('TopHour'), + topSixHour('TopSixHour'), + topTwelveHour('TopTwelveHour'), + topDay('TopDay'), + topWeek('TopWeek'), + topMonth('TopMonth'), + topThreeMonths('TopThreeMonths'), + topSixMonths('TopSixMonths'), + topNineMonths('TopNineMonths'), + topYear('TopYear'), + topAll('TopAll'); + + /// The value of the sort type for the API. + final String value; + + /// The platform this sort type is used on. If null, it is used on all platforms. + final ThreadiversePlatform? platform; + + const SearchSortType(this.value, {this.platform}); +} diff --git a/lib/src/core/network/lemmy_api.dart b/lib/src/core/network/lemmy_api.dart index 00f27a1d0..c2f5d9e64 100644 --- a/lib/src/core/network/lemmy_api.dart +++ b/lib/src/core/network/lemmy_api.dart @@ -17,6 +17,7 @@ import 'package:thunder/src/core/enums/comment_sort_type.dart'; import 'package:thunder/src/core/enums/feed_list_type.dart'; import 'package:thunder/src/core/enums/meta_search_type.dart'; import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/core/enums/search_sort_type.dart'; import 'package:thunder/src/core/models/thunder_site.dart'; import 'package:thunder/src/features/modlog/modlog.dart'; import 'package:thunder/src/features/post/post.dart'; @@ -509,7 +510,7 @@ class LemmyApi { String? communityName, int? creatorId, MetaSearchType? type, - PostSortType? sort, + SearchSortType? sort, FeedListType? listingType, int? page, int? limit, diff --git a/lib/src/core/network/piefed_api.dart b/lib/src/core/network/piefed_api.dart index da6bac47f..0c4e5c319 100644 --- a/lib/src/core/network/piefed_api.dart +++ b/lib/src/core/network/piefed_api.dart @@ -15,6 +15,7 @@ import 'package:thunder/src/core/enums/comment_sort_type.dart'; import 'package:thunder/src/core/enums/feed_list_type.dart'; import 'package:thunder/src/core/enums/meta_search_type.dart'; import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/core/enums/search_sort_type.dart'; import 'package:thunder/src/core/models/thunder_site.dart'; import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/features/user/user.dart'; @@ -415,7 +416,7 @@ class PiefedApi { Future> search({ required String query, MetaSearchType? type, - PostSortType? sort, + SearchSortType? sort, FeedListType? listingType, int? page, int? limit, diff --git a/lib/src/features/community/presentation/widgets/community_header/community_header_actions.dart b/lib/src/features/community/presentation/widgets/community_header/community_header_actions.dart index 19baa8f78..bae610825 100644 --- a/lib/src/features/community/presentation/widgets/community_header/community_header_actions.dart +++ b/lib/src/features/community/presentation/widgets/community_header/community_header_actions.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:thunder/src/core/enums/post_sort_type.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/core/enums/full_name.dart'; @@ -149,7 +150,7 @@ class _SortActionChip extends StatelessWidget { showDragHandle: true, context: context, isScrollControlled: true, - builder: (builderContext) => SortPicker( + builder: (builderContext) => SortPicker( account: feedBloc.account, title: l10n.sortOptions, onSelect: (selected) async { diff --git a/lib/src/features/feed/presentation/widgets/feed_fab.dart b/lib/src/features/feed/presentation/widgets/feed_fab.dart index f36b0e40f..460ac7450 100644 --- a/lib/src/features/feed/presentation/widgets/feed_fab.dart +++ b/lib/src/features/feed/presentation/widgets/feed_fab.dart @@ -7,6 +7,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/app/cubits/feed_ui_cubit/feed_ui_cubit.dart'; +import 'package:thunder/src/core/enums/post_sort_type.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/core/enums/fab_action.dart'; import 'package:thunder/src/features/feed/feed.dart'; @@ -277,7 +278,7 @@ class FeedFAB extends StatelessWidget { showDragHandle: true, context: context, isScrollControlled: true, - builder: (builderContext) => SortPicker( + builder: (builderContext) => SortPicker( account: feedBloc.account, title: l10n.sortOptions, onSelect: (selected) async => context.read().add(FeedChangePostSortTypeEvent(selected.payload)), diff --git a/lib/src/features/feed/presentation/widgets/feed_page_app_bar.dart b/lib/src/features/feed/presentation/widgets/feed_page_app_bar.dart index 0f1174b60..793e3db68 100644 --- a/lib/src/features/feed/presentation/widgets/feed_page_app_bar.dart +++ b/lib/src/features/feed/presentation/widgets/feed_page_app_bar.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:thunder/src/core/enums/post_sort_type.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/feed/feed.dart'; import 'package:thunder/src/shared/utils/constants.dart'; @@ -167,7 +168,7 @@ class FeedAppBarCommunityActions extends StatelessWidget { showDragHandle: true, context: context, isScrollControlled: true, - builder: (builderContext) => SortPicker( + builder: (builderContext) => SortPicker( account: feedBloc.account, title: l10n.sortOptions, onSelect: (selected) async => context.read().add(FeedChangePostSortTypeEvent(selected.payload)), @@ -211,7 +212,7 @@ class FeedAppBarUserActions extends StatelessWidget { showDragHandle: true, context: context, isScrollControlled: true, - builder: (builderContext) => SortPicker( + builder: (builderContext) => SortPicker( account: feedBloc.account, title: l10n.sortOptions, onSelect: (selected) async => feedBloc.add(FeedChangePostSortTypeEvent(selected.payload)), @@ -253,7 +254,7 @@ class FeedAppBarGeneralActions extends StatelessWidget { showDragHandle: true, context: context, isScrollControlled: true, - builder: (builderContext) => SortPicker( + builder: (builderContext) => SortPicker( account: feedBloc.account, title: l10n.sortOptions, onSelect: (selected) async => feedBloc.add(FeedChangePostSortTypeEvent(selected.payload)), diff --git a/lib/src/features/inbox/presentation/pages/inbox_page.dart b/lib/src/features/inbox/presentation/pages/inbox_page.dart index ba3a98998..48c38175f 100644 --- a/lib/src/features/inbox/presentation/pages/inbox_page.dart +++ b/lib/src/features/inbox/presentation/pages/inbox_page.dart @@ -6,7 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/core/enums/comment_sort_type.dart'; import 'package:thunder/src/features/inbox/inbox.dart'; -import 'package:thunder/src/shared/comment_sort_picker.dart'; +import 'package:thunder/src/shared/sort_picker.dart'; import 'package:thunder/src/shared/dialogs.dart'; import 'package:thunder/src/shared/snackbar.dart'; import 'package:thunder/src/shared/widgets/thunder_popup_menu_item.dart'; @@ -76,7 +76,7 @@ class _InboxPageState extends State with SingleTickerProviderStateMix showModalBottomSheet( showDragHandle: true, context: context, - builder: (builderContext) => CommentSortPicker( + builder: (builderContext) => SortPicker( account: inboxBloc.account, title: l10n.sortOptions, onSelect: (selected) async { diff --git a/lib/src/features/instance/presentation/bloc/instance_page_event.dart b/lib/src/features/instance/presentation/bloc/instance_page_event.dart index 7820b6a71..efc85de26 100644 --- a/lib/src/features/instance/presentation/bloc/instance_page_event.dart +++ b/lib/src/features/instance/presentation/bloc/instance_page_event.dart @@ -1,7 +1,7 @@ import 'package:equatable/equatable.dart'; import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/core/enums/search_sort_type.dart'; abstract class InstancePageEvent extends Equatable { const InstancePageEvent(); @@ -12,7 +12,7 @@ abstract class InstancePageEvent extends Equatable { class GetInstanceCommunities extends InstancePageEvent { final int? page; - final PostSortType sortType; + final SearchSortType sortType; final String? query; const GetInstanceCommunities({this.page, required this.sortType, this.query}); @@ -23,7 +23,7 @@ class GetInstanceCommunities extends InstancePageEvent { class GetInstanceUsers extends InstancePageEvent { final int? page; - final PostSortType sortType; + final SearchSortType sortType; final String? query; const GetInstanceUsers({this.page, required this.sortType, this.query}); @@ -34,7 +34,7 @@ class GetInstanceUsers extends InstancePageEvent { class GetInstancePosts extends InstancePageEvent { final int? page; - final PostSortType sortType; + final SearchSortType sortType; final String? query; const GetInstancePosts({this.page, required this.sortType, this.query}); @@ -45,7 +45,7 @@ class GetInstancePosts extends InstancePageEvent { class GetInstanceComments extends InstancePageEvent { final int? page; - final PostSortType sortType; + final SearchSortType sortType; final String? query; const GetInstanceComments({this.page, required this.sortType, this.query}); diff --git a/lib/src/features/instance/presentation/pages/instance_page.dart b/lib/src/features/instance/presentation/pages/instance_page.dart index 2bfc885ea..0136f817c 100644 --- a/lib/src/features/instance/presentation/pages/instance_page.dart +++ b/lib/src/features/instance/presentation/pages/instance_page.dart @@ -4,7 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/app/utils/global_context.dart'; import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/core/enums/search_sort_type.dart'; import 'package:thunder/src/core/models/models.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/feed/feed.dart'; @@ -35,7 +35,7 @@ class _InstancePageState extends State with SingleTickerProviderSt late final TabController _tabController; /// The post sort type to use - PostSortType postSortType = PostSortType.topAll; + SearchSortType searchSortType = SearchSortType.topAll; /// Context for [_onScroll] to use to find the proper cubit BuildContext? buildContext; @@ -69,19 +69,19 @@ class _InstancePageState extends State with SingleTickerProviderSt switch (_tabController.index) { case 1: bloc.add(ResetInstanceTabs(excludeType: MetaSearchType.communities)); - bloc.add(GetInstanceCommunities(page: 1, sortType: postSortType, query: query)); + bloc.add(GetInstanceCommunities(page: 1, sortType: searchSortType, query: query)); break; case 2: bloc.add(ResetInstanceTabs(excludeType: MetaSearchType.users)); - bloc.add(GetInstanceUsers(page: 1, sortType: postSortType, query: query)); + bloc.add(GetInstanceUsers(page: 1, sortType: searchSortType, query: query)); break; case 3: bloc.add(ResetInstanceTabs(excludeType: MetaSearchType.posts)); - bloc.add(GetInstancePosts(page: 1, sortType: postSortType, query: query)); + bloc.add(GetInstancePosts(page: 1, sortType: searchSortType, query: query)); break; case 4: bloc.add(ResetInstanceTabs(excludeType: MetaSearchType.comments)); - bloc.add(GetInstanceComments(page: 1, sortType: postSortType, query: query)); + bloc.add(GetInstanceComments(page: 1, sortType: searchSortType, query: query)); break; default: bloc.add(const ResetInstanceTabs(excludeType: null)); @@ -97,16 +97,16 @@ class _InstancePageState extends State with SingleTickerProviderSt switch (_tabController.index) { case 1: - if (bloc.state.communities.items.isEmpty) bloc.add(GetInstanceCommunities(sortType: postSortType, query: query)); + if (bloc.state.communities.items.isEmpty) bloc.add(GetInstanceCommunities(sortType: searchSortType, query: query)); break; case 2: - if (bloc.state.users.items.isEmpty) bloc.add(GetInstanceUsers(sortType: postSortType, query: query)); + if (bloc.state.users.items.isEmpty) bloc.add(GetInstanceUsers(sortType: searchSortType, query: query)); break; case 3: - if (bloc.state.posts.items.isEmpty) bloc.add(GetInstancePosts(sortType: postSortType, query: query)); + if (bloc.state.posts.items.isEmpty) bloc.add(GetInstancePosts(sortType: searchSortType, query: query)); break; case 4: - if (bloc.state.comments.items.isEmpty) bloc.add(GetInstanceComments(sortType: postSortType, query: query)); + if (bloc.state.comments.items.isEmpty) bloc.add(GetInstanceComments(sortType: searchSortType, query: query)); break; } } @@ -139,10 +139,10 @@ class _InstancePageState extends State with SingleTickerProviderSt handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), sliver: InstancePageAppBar( instance: widget.instance, - postSortType: postSortType, + searchSortType: searchSortType, account: account, onSortSelected: (sortType) { - setState(() => postSortType = sortType); + setState(() => searchSortType = sortType); _onRefresh(); }, onQueryChanged: (query) { @@ -213,26 +213,26 @@ class _InstancePageState extends State with SingleTickerProviderSt InstanceCommunityTab( account: account, query: query, - postSortType: postSortType, - onRetry: () => context.read().add(GetInstanceCommunities(sortType: postSortType, query: query)), + searchSortType: searchSortType, + onRetry: () => context.read().add(GetInstanceCommunities(sortType: searchSortType, query: query)), ), InstanceUserTab( account: account, query: query, - postSortType: postSortType, - onRetry: () => context.read().add(GetInstanceUsers(sortType: postSortType, query: query)), + searchSortType: searchSortType, + onRetry: () => context.read().add(GetInstanceUsers(sortType: searchSortType, query: query)), ), InstancePostTab( account: account, query: query, - postSortType: postSortType, - onRetry: () => context.read().add(GetInstancePosts(sortType: postSortType, query: query)), + searchSortType: searchSortType, + onRetry: () => context.read().add(GetInstancePosts(sortType: searchSortType, query: query)), ), InstanceCommentTab( account: account, query: query, - postSortType: postSortType, - onRetry: () => context.read().add(GetInstanceComments(sortType: postSortType, query: query)), + searchSortType: searchSortType, + onRetry: () => context.read().add(GetInstanceComments(sortType: searchSortType, query: query)), ), ], ), diff --git a/lib/src/features/instance/presentation/widgets/instance_page_app_bar.dart b/lib/src/features/instance/presentation/widgets/instance_page_app_bar.dart index 314c3838f..853decc49 100644 --- a/lib/src/features/instance/presentation/widgets/instance_page_app_bar.dart +++ b/lib/src/features/instance/presentation/widgets/instance_page_app_bar.dart @@ -7,7 +7,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/app/utils/global_context.dart'; import 'package:thunder/src/app/utils/navigation.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/core/enums/search_sort_type.dart'; import 'package:thunder/src/core/enums/threadiverse_platform.dart'; import 'package:thunder/src/core/models/models.dart'; import 'package:thunder/src/features/account/account.dart'; @@ -23,13 +23,13 @@ class InstancePageAppBar extends StatefulWidget { final ThunderInstanceInfo instance; /// The sort type for the instance's data. - final PostSortType postSortType; + final SearchSortType searchSortType; /// The account being used. final Account account; /// Callback for when the sort type is changed. - final Function(PostSortType sortType) onSortSelected; + final Function(SearchSortType sortType) onSortSelected; /// Widget to be displayed at the bottom of the app bar. final PreferredSizeWidget? bottom; @@ -40,7 +40,7 @@ class InstancePageAppBar extends StatefulWidget { const InstancePageAppBar({ super.key, required this.instance, - required this.postSortType, + required this.searchSortType, required this.account, required this.onSortSelected, required this.onQueryChanged, @@ -107,13 +107,13 @@ class _InstancePageAppBarState extends State { showDragHandle: true, context: context, isScrollControlled: true, - builder: (builderContext) => SortPicker( + builder: (builderContext) => SortPicker( account: account, title: l10n.sortOptions, onSelect: (selected) async { widget.onSortSelected(selected.payload); }, - previouslySelected: widget.postSortType, + previouslySelected: widget.searchSortType, ), ); }, diff --git a/lib/src/features/instance/presentation/widgets/instance_tabs.dart b/lib/src/features/instance/presentation/widgets/instance_tabs.dart index beb8b724a..8fbd34d05 100644 --- a/lib/src/features/instance/presentation/widgets/instance_tabs.dart +++ b/lib/src/features/instance/presentation/widgets/instance_tabs.dart @@ -4,7 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/app/thunder.dart'; import 'package:thunder/src/app/utils/global_context.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/core/enums/search_sort_type.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/comment/comment.dart'; import 'package:thunder/src/features/community/community.dart'; @@ -121,8 +121,8 @@ class InstanceCommunityTab extends StatelessWidget { /// The account to use for the tab. final Account account; - /// The post sort type to use for the tab. - final PostSortType postSortType; + /// The search sort type to use for the tab. + final SearchSortType searchSortType; /// Callback to retry loading items. final VoidCallback onRetry; @@ -130,7 +130,7 @@ class InstanceCommunityTab extends StatelessWidget { /// The query to use for the tab. final String? query; - const InstanceCommunityTab({super.key, required this.account, required this.postSortType, required this.onRetry, this.query}); + const InstanceCommunityTab({super.key, required this.account, required this.searchSortType, required this.onRetry, this.query}); @override Widget build(BuildContext context) { @@ -141,7 +141,7 @@ class InstanceCommunityTab extends StatelessWidget { state: state.communities, storageKey: 'communities', onRetry: onRetry, - onLoadMore: () => context.read().add(GetInstanceCommunities(page: state.communities.page + 1, sortType: postSortType, query: query)), + onLoadMore: () => context.read().add(GetInstanceCommunities(page: state.communities.page + 1, sortType: searchSortType, query: query)), itemBuilder: (context, item) => CommunityListEntry(community: item, resolutionAccount: account), ); }, @@ -153,8 +153,8 @@ class InstanceUserTab extends StatelessWidget { /// The account to use for the tab. final Account account; - /// The post sort type to use for the tab. - final PostSortType postSortType; + /// The search sort type to use for the tab. + final SearchSortType searchSortType; /// Callback to retry loading items. final VoidCallback onRetry; @@ -162,7 +162,7 @@ class InstanceUserTab extends StatelessWidget { /// The query to use for the tab. final String? query; - const InstanceUserTab({super.key, required this.account, required this.postSortType, required this.onRetry, this.query}); + const InstanceUserTab({super.key, required this.account, required this.searchSortType, required this.onRetry, this.query}); @override Widget build(BuildContext context) { @@ -173,7 +173,7 @@ class InstanceUserTab extends StatelessWidget { state: state.users, storageKey: 'users', onRetry: onRetry, - onLoadMore: () => context.read().add(GetInstanceUsers(page: state.users.page + 1, sortType: postSortType, query: query)), + onLoadMore: () => context.read().add(GetInstanceUsers(page: state.users.page + 1, sortType: searchSortType, query: query)), itemBuilder: (context, item) => UserListEntry(user: item, resolutionAccount: account), ); }, @@ -185,8 +185,8 @@ class InstancePostTab extends StatelessWidget { /// The account to use for the tab. final Account account; - /// The post sort type to use for the tab. - final PostSortType postSortType; + /// The search sort type to use for the tab. + final SearchSortType searchSortType; /// Callback to retry loading items. final VoidCallback onRetry; @@ -194,7 +194,7 @@ class InstancePostTab extends StatelessWidget { /// The query to use for the tab. final String? query; - const InstancePostTab({super.key, required this.account, required this.postSortType, required this.onRetry, this.query}); + const InstancePostTab({super.key, required this.account, required this.searchSortType, required this.onRetry, this.query}); @override Widget build(BuildContext context) { @@ -207,7 +207,7 @@ class InstancePostTab extends StatelessWidget { state: state.posts, storageKey: 'posts', onRetry: onRetry, - onLoadMore: () => context.read().add(GetInstancePosts(page: state.posts.page + 1, sortType: postSortType, query: query)), + onLoadMore: () => context.read().add(GetInstancePosts(page: state.posts.page + 1, sortType: searchSortType, query: query)), loadingWidget: SliverMainAxisGroup( slivers: [ FeedPostCardList( @@ -229,8 +229,8 @@ class InstanceCommentTab extends StatelessWidget { /// The account to use for the tab. final Account account; - /// The post sort type to use for the tab. - final PostSortType postSortType; + /// The search sort type to use for the tab. + final SearchSortType searchSortType; /// Callback to retry loading items. final VoidCallback onRetry; @@ -238,7 +238,7 @@ class InstanceCommentTab extends StatelessWidget { /// The query to use for the tab. final String? query; - const InstanceCommentTab({super.key, required this.account, required this.postSortType, required this.onRetry, this.query}); + const InstanceCommentTab({super.key, required this.account, required this.searchSortType, required this.onRetry, this.query}); @override Widget build(BuildContext context) { @@ -249,7 +249,7 @@ class InstanceCommentTab extends StatelessWidget { state: state.comments, storageKey: 'comments', onRetry: onRetry, - onLoadMore: () => context.read().add(GetInstanceComments(page: state.comments.page + 1, sortType: postSortType, query: query)), + onLoadMore: () => context.read().add(GetInstanceComments(page: state.comments.page + 1, sortType: searchSortType, query: query)), itemBuilder: (context, item) => CommentListEntry(comment: item), ); }, diff --git a/lib/src/features/post/presentation/pages/create_post_page.dart b/lib/src/features/post/presentation/pages/create_post_page.dart index b2dde9d50..4e5fbd575 100644 --- a/lib/src/features/post/presentation/pages/create_post_page.dart +++ b/lib/src/features/post/presentation/pages/create_post_page.dart @@ -14,9 +14,9 @@ import 'package:markdown_editor/markdown_editor.dart'; // Project imports import 'package:thunder/src/app/cubits/feed_preferences_cubit/feed_preferences_cubit.dart'; +import 'package:thunder/src/core/enums/search_sort_type.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; import 'package:thunder/src/core/models/thunder_language.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/features/account/account.dart'; @@ -784,7 +784,7 @@ class _CreatePostPageState extends State { final response = await SearchRepositoryImpl(account: account!).search( query: url, type: MetaSearchType.url, - sort: PostSortType.topAll, + sort: SearchSortType.topAll, listingType: FeedListType.all, limit: 20, ); diff --git a/lib/src/features/post/presentation/widgets/post_page_app_bar.dart b/lib/src/features/post/presentation/widgets/post_page_app_bar.dart index 2961207b9..f499658ed 100644 --- a/lib/src/features/post/presentation/widgets/post_page_app_bar.dart +++ b/lib/src/features/post/presentation/widgets/post_page_app_bar.dart @@ -8,7 +8,7 @@ import 'package:thunder/src/core/enums/comment_sort_type.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/core/enums/media_type.dart'; import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/shared/comment_sort_picker.dart'; +import 'package:thunder/src/shared/sort_picker.dart'; import 'package:thunder/src/shared/widgets/thunder_popup_menu_item.dart'; import 'package:thunder/src/app/bloc/thunder_bloc.dart'; import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; @@ -211,7 +211,7 @@ class PostAppBarActions extends StatelessWidget { showModalBottomSheet( showDragHandle: true, context: context, - builder: (builderContext) => CommentSortPicker( + builder: (builderContext) => SortPicker( account: postBloc.account, title: l10n.sortOptions, onSelect: (selected) async { diff --git a/lib/src/features/post/presentation/widgets/post_page_fab.dart b/lib/src/features/post/presentation/widgets/post_page_fab.dart index 0279b09fe..3efba3377 100644 --- a/lib/src/features/post/presentation/widgets/post_page_fab.dart +++ b/lib/src/features/post/presentation/widgets/post_page_fab.dart @@ -10,10 +10,11 @@ import 'package:thunder/src/app/utils/navigation.dart'; import 'package:thunder/src/app/cubits/fab_preferences_cubit/fab_preferences_cubit.dart'; import 'package:thunder/src/app/cubits/fab_cubit/fab_cubit.dart'; import 'package:thunder/src/core/enums/fab_action.dart'; +import 'package:thunder/src/core/enums/comment_sort_type.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/comment/comment.dart'; import 'package:thunder/src/features/post/post.dart'; -import 'package:thunder/src/shared/comment_sort_picker.dart'; +import 'package:thunder/src/shared/sort_picker.dart'; import 'package:thunder/src/shared/gesture_fab.dart'; import 'package:thunder/src/shared/input_dialogs.dart'; import 'package:thunder/src/shared/snackbar.dart'; @@ -56,7 +57,7 @@ class _PostPageFABState extends State { showModalBottomSheet( showDragHandle: true, context: context, - builder: (builderContext) => CommentSortPicker( + builder: (builderContext) => SortPicker( account: account, title: l10n.sortOptions, onSelect: (selected) async { diff --git a/lib/src/features/search/data/repositories/search_repository.dart b/lib/src/features/search/data/repositories/search_repository.dart index dde6d2dd6..928b55d45 100644 --- a/lib/src/features/search/data/repositories/search_repository.dart +++ b/lib/src/features/search/data/repositories/search_repository.dart @@ -9,7 +9,7 @@ import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/core/network/piefed_api.dart'; import 'package:thunder/src/core/enums/feed_list_type.dart'; import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/core/enums/search_sort_type.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/core/enums/threadiverse_platform.dart'; import 'package:thunder/src/features/post/post.dart'; @@ -23,7 +23,7 @@ abstract class SearchRepository { Future> search({ required String query, MetaSearchType? type, - PostSortType? sort, + SearchSortType? sort, FeedListType? listingType, int? limit, int? page, @@ -65,7 +65,7 @@ class SearchRepositoryImpl implements SearchRepository { Future> search({ required String query, MetaSearchType? type, - PostSortType? sort, + SearchSortType? sort, FeedListType? listingType, int? limit, int? page, diff --git a/lib/src/features/search/presentation/bloc/search_bloc.dart b/lib/src/features/search/presentation/bloc/search_bloc.dart index ed04ab72e..050dbc203 100644 --- a/lib/src/features/search/presentation/bloc/search_bloc.dart +++ b/lib/src/features/search/presentation/bloc/search_bloc.dart @@ -12,7 +12,7 @@ import 'package:thunder/src/features/instance/instance.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/core/enums/enums.dart'; import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/core/enums/search_sort_type.dart'; import 'package:thunder/src/core/models/models.dart'; import 'package:thunder/src/features/post/post.dart'; import 'package:thunder/src/features/search/search.dart'; @@ -145,7 +145,7 @@ class SearchBloc extends Bloc { final response = await searchRepository.search( query: event.query, type: effectiveSearchType, - sort: state.postSortType ?? PostSortType.active, + sort: state.searchSortType ?? SearchSortType.topYear, listingType: state.feedListType, limit: searchResultsPerPage, page: 1, @@ -229,7 +229,7 @@ class SearchBloc extends Bloc { final response = await searchRepository.search( query: event.query, type: effectiveSearchType, - sort: state.postSortType ?? PostSortType.active, + sort: state.searchSortType ?? SearchSortType.topYear, listingType: state.feedListType, limit: searchResultsPerPage, page: state.page, @@ -294,7 +294,7 @@ class SearchBloc extends Bloc { void _onFiltersUpdated(SearchFiltersUpdated event, Emitter emit) { emit( state.copyWith( - postSortType: event.sortType, + searchSortType: event.sortType, sortTypeIcon: event.sortTypeIcon, sortTypeLabel: event.sortTypeLabel, searchType: event.searchType, diff --git a/lib/src/features/search/presentation/bloc/search_event.dart b/lib/src/features/search/presentation/bloc/search_event.dart index 66eef9803..46a6d38c6 100644 --- a/lib/src/features/search/presentation/bloc/search_event.dart +++ b/lib/src/features/search/presentation/bloc/search_event.dart @@ -62,7 +62,7 @@ final class TrendingCommunitiesRequested extends SearchEvent { /// Updated the search filters. class SearchFiltersUpdated extends SearchEvent { - final PostSortType? sortType; + final SearchSortType? sortType; final IconData? sortTypeIcon; final String? sortTypeLabel; final MetaSearchType? searchType; diff --git a/lib/src/features/search/presentation/bloc/search_state.dart b/lib/src/features/search/presentation/bloc/search_state.dart index b6b7a4751..d19e1792c 100644 --- a/lib/src/features/search/presentation/bloc/search_state.dart +++ b/lib/src/features/search/presentation/bloc/search_state.dart @@ -14,7 +14,7 @@ class SearchState extends Equatable { this.message, this.page = 1, this.hasReachedMax = false, - this.postSortType, + this.searchSortType, this.sortTypeIcon, this.sortTypeLabel, this.focusSearchId = 0, @@ -38,7 +38,7 @@ class SearchState extends Equatable { final FeedListType feedListType; /// The sort type to use for the search - final PostSortType? postSortType; + final SearchSortType? searchSortType; /// The icon for the sort type final IconData? sortTypeIcon; @@ -108,7 +108,7 @@ class SearchState extends Equatable { String? message, int? page, bool? hasReachedMax, - PostSortType? postSortType, + SearchSortType? searchSortType, IconData? sortTypeIcon, String? sortTypeLabel, int? focusSearchId, @@ -134,7 +134,7 @@ class SearchState extends Equatable { message: message ?? this.message, page: page ?? this.page, hasReachedMax: hasReachedMax ?? this.hasReachedMax, - postSortType: postSortType ?? this.postSortType, + searchSortType: searchSortType ?? this.searchSortType, sortTypeIcon: sortTypeIcon ?? this.sortTypeIcon, sortTypeLabel: sortTypeLabel ?? this.sortTypeLabel, focusSearchId: focusSearchId ?? this.focusSearchId, @@ -161,7 +161,7 @@ class SearchState extends Equatable { message, page, hasReachedMax, - postSortType, + searchSortType, sortTypeIcon, sortTypeLabel, focusSearchId, diff --git a/lib/src/features/search/presentation/pages/search_page.dart b/lib/src/features/search/presentation/pages/search_page.dart index 0908565b7..d672d6be9 100644 --- a/lib/src/features/search/presentation/pages/search_page.dart +++ b/lib/src/features/search/presentation/pages/search_page.dart @@ -6,7 +6,7 @@ import 'package:back_button_interceptor/back_button_interceptor.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/core/enums/search_sort_type.dart'; import 'package:thunder/src/core/singletons/preferences.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/community/community.dart'; @@ -79,8 +79,8 @@ class _SearchPageState extends State with AutomaticKeepAliveClientMi void initializePreferences() { final prefs = UserPreferences.instance.preferences; - final sortType = PostSortType.values.byName(prefs.getString("search_default_sort_type") ?? DEFAULT_SEARCH_POST_SORT_TYPE.name); - final sortTypeItem = allPostSortTypeItems.firstWhere((item) => item.payload == sortType); + final sortType = SearchSortType.values.byName(prefs.getString("search_default_sort_type") ?? DEFAULT_SEARCH_SORT_TYPE.name); + final sortTypeItem = allSearchSortTypeItems.firstWhere((item) => item.payload == sortType); context.read().add(SearchFiltersUpdated(sortType: sortType, sortTypeIcon: sortTypeItem.icon, sortTypeLabel: sortTypeItem.label)); } @@ -149,7 +149,7 @@ class _SearchPageState extends State with AutomaticKeepAliveClientMi context: context, showDragHandle: true, isScrollControlled: true, - builder: (builderContext) => SortPicker( + builder: (builderContext) => SortPicker( account: feedBloc.account, title: l10n.sortOptions, onSelect: (selected) async { @@ -157,7 +157,7 @@ class _SearchPageState extends State with AutomaticKeepAliveClientMi prefs.setString("search_default_sort_type", selected.payload.name); search(); }, - previouslySelected: searchBloc.state.postSortType ?? PostSortType.active, + previouslySelected: searchBloc.state.searchSortType ?? DEFAULT_SEARCH_SORT_TYPE, ), ); } diff --git a/lib/src/features/settings/presentation/pages/general_settings_page.dart b/lib/src/features/settings/presentation/pages/general_settings_page.dart index 488e224cb..d1ca34afe 100644 --- a/lib/src/features/settings/presentation/pages/general_settings_page.dart +++ b/lib/src/features/settings/presentation/pages/general_settings_page.dart @@ -23,7 +23,6 @@ import 'package:thunder/src/core/enums/local_settings.dart'; import 'package:thunder/src/core/singletons/preferences.dart'; import 'package:thunder/src/features/notification/notification.dart'; import 'package:thunder/src/features/settings/settings.dart'; -import 'package:thunder/src/shared/comment_sort_picker.dart'; import 'package:thunder/src/shared/markdown/common_markdown_body.dart'; import 'package:thunder/src/shared/dialogs.dart'; import 'package:thunder/src/shared/divider.dart'; @@ -408,7 +407,7 @@ class _GeneralSettingsPageState extends State with SingleTi icon: Icons.sort_rounded, onChanged: (_) async {}, isBottomModalScrollControlled: true, - customListPicker: SortPicker( + customListPicker: SortPicker( title: l10n.defaultFeedSortType, onSelect: (value) async { setPreferences(LocalSettings.defaultFeedPostSortType, value.payload.name); @@ -579,7 +578,7 @@ class _GeneralSettingsPageState extends State with SingleTi options: getCommentSortTypeItems(), icon: Icons.comment_bank_rounded, onChanged: (_) async {}, - customListPicker: CommentSortPicker( + customListPicker: SortPicker( title: l10n.commentSortType, onSelect: (value) async { setPreferences(LocalSettings.defaultCommentSortType, value.payload.name); diff --git a/lib/src/features/user/presentation/pages/user_settings_page.dart b/lib/src/features/user/presentation/pages/user_settings_page.dart index e9467ab21..476ec78cb 100644 --- a/lib/src/features/user/presentation/pages/user_settings_page.dart +++ b/lib/src/features/user/presentation/pages/user_settings_page.dart @@ -12,6 +12,7 @@ import "package:path_provider/path_provider.dart"; import 'package:markdown/markdown.dart' hide Text; import "package:thunder/src/core/enums/threadiverse_platform.dart"; +import "package:thunder/src/core/enums/post_sort_type.dart"; import "package:thunder/src/core/models/thunder_local_user.dart"; import "package:thunder/src/core/models/thunder_my_user.dart"; import "package:thunder/src/core/models/thunder_site_response.dart"; @@ -336,7 +337,7 @@ class _UserSettingsPageState extends State { icon: Icons.sort_rounded, onChanged: (_) async {}, isBottomModalScrollControlled: true, - customListPicker: SortPicker( + customListPicker: SortPicker( account: account, title: l10n.defaultFeedSortType, onSelect: (value) async { diff --git a/lib/src/features/user/presentation/widgets/user_header/user_header_actions.dart b/lib/src/features/user/presentation/widgets/user_header/user_header_actions.dart index 9c6804c39..7d04defd9 100644 --- a/lib/src/features/user/presentation/widgets/user_header/user_header_actions.dart +++ b/lib/src/features/user/presentation/widgets/user_header/user_header_actions.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:thunder/src/core/enums/post_sort_type.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/features/feed/feed.dart'; @@ -273,7 +274,7 @@ class _SortActionChip extends StatelessWidget { showDragHandle: true, context: context, isScrollControlled: true, - builder: (builderContext) => SortPicker( + builder: (builderContext) => SortPicker( account: feedBloc.account, title: l10n.sortOptions, onSelect: (selected) async { diff --git a/lib/src/shared/comment_sort_picker.dart b/lib/src/shared/comment_sort_picker.dart deleted file mode 100644 index 71bdee236..000000000 --- a/lib/src/shared/comment_sort_picker.dart +++ /dev/null @@ -1,112 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:thunder/src/features/account/account.dart'; -import 'package:thunder/src/core/enums/comment_sort_type.dart'; -import 'package:thunder/src/shared/picker_item.dart'; -import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; -import 'package:thunder/src/app/utils/global_context.dart'; - -List> getCommentSortTypeItems({Account? account}) { - final l10n = GlobalContext.l10n; - final platform = account?.platform; - - List> commentSortTypeItems = [ - ListPickerItem( - payload: CommentSortType.hot, - icon: Icons.local_fire_department, - label: l10n.hot, - ), - ListPickerItem( - payload: CommentSortType.top, - icon: Icons.military_tech, - label: l10n.top, - ), - ListPickerItem( - payload: CommentSortType.controversial, - icon: Icons.warning_rounded, - label: l10n.controversial, - ), - ListPickerItem( - payload: CommentSortType.new_, - icon: Icons.auto_awesome_rounded, - label: l10n.new_, - ), - ListPickerItem( - payload: CommentSortType.old, - icon: Icons.access_time_outlined, - label: l10n.old, - ), - ]; - - if (platform == null) return commentSortTypeItems; - - // Only return the sort types that are available for the platform (or all platforms). - return commentSortTypeItems.where((item) => item.payload.platform == platform || item.payload.platform == null).toList(); -} - -/// Create a picker which allows selecting a valid comment sort type. -class CommentSortPicker extends BottomSheetListPicker { - final Account? account; - - CommentSortPicker({ - super.key, - this.account, - required super.onSelect, - required super.title, - super.previouslySelected, - }) : super(items: getCommentSortTypeItems(account: account)); - - @override - State createState() => _SortPickerState(); -} - -class _SortPickerState extends State { - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - child: AnimatedSize( - duration: const Duration(milliseconds: 400), - curve: Curves.easeInOutCubicEmphasized, - child: defaultSortPicker(), - ), - ); - } - - Widget defaultSortPicker() { - final theme = Theme.of(context); - - return Column( - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.max, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 16.0, left: 26.0, right: 16.0), - child: Align( - alignment: Alignment.centerLeft, - child: Text(widget.title, style: theme.textTheme.titleLarge), - ), - ), - ListView( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - children: [..._generateList(getCommentSortTypeItems(account: widget.account), theme)], - ), - const SizedBox(height: 16.0), - ], - ); - } - - List _generateList(List> items, ThemeData theme) { - return items - .map((item) => PickerItem( - label: item.label, - icon: item.icon, - onSelected: () { - Navigator.of(context).pop(); - widget.onSelect?.call(item); - }, - isSelected: widget.previouslySelected == item.payload, - )) - .toList(); - } -} diff --git a/lib/src/shared/input_dialogs.dart b/lib/src/shared/input_dialogs.dart index ab34fa6bd..4bd5d7ddd 100644 --- a/lib/src/shared/input_dialogs.dart +++ b/lib/src/shared/input_dialogs.dart @@ -7,9 +7,9 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:collection/collection.dart'; +import 'package:thunder/src/core/enums/search_sort_type.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/core/enums/meta_search_type.dart'; -import 'package:thunder/src/core/enums/post_sort_type.dart'; import 'package:thunder/src/core/models/thunder_language.dart'; import 'package:thunder/src/features/instance/instance.dart'; import 'package:thunder/l10n/generated/app_localizations.dart'; @@ -203,7 +203,7 @@ Future> getCommunitySuggestions( query: query, type: MetaSearchType.communities, limit: 20, - sort: PostSortType.topAll, + sort: SearchSortType.topAll, ); return prioritizeFavorites(response['communities'], favoritedCommunities) ?? []; diff --git a/lib/src/shared/sort_picker.dart b/lib/src/shared/sort_picker.dart index 74e525f63..9d35067f0 100644 --- a/lib/src/shared/sort_picker.dart +++ b/lib/src/shared/sort_picker.dart @@ -2,10 +2,17 @@ import 'package:flutter/material.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/core/enums/comment_sort_type.dart'; +import 'package:thunder/src/core/enums/search_sort_type.dart'; import 'package:thunder/src/shared/picker_item.dart'; import 'package:thunder/src/shared/utils/bottom_sheet_list_picker.dart'; import 'package:thunder/src/app/utils/global_context.dart'; +// ============================================================================ +// Post Sort Type Items +// ============================================================================ + +/// Returns the "Top" sort type items for posts (TopHour, TopDay, etc.) List> getTopPostSortTypeItems({Account? account}) { final l10n = GlobalContext.l10n; final platform = account?.platform; @@ -74,6 +81,7 @@ List> getTopPostSortTypeItems({Account? account}) { return topPostSortTypeItems.where((item) => item.payload.platform == platform || item.payload.platform == null).toList(); } +/// Returns the default (non-Top) sort type items for posts List> getDefaultPostSortTypeItems({Account? account}) { final l10n = GlobalContext.l10n; final platform = account?.platform; @@ -127,10 +135,188 @@ List> getDefaultPostSortTypeItems({Account? account return defaultPostSortTypeItems.where((item) => item.payload.platform == platform || item.payload.platform == null).toList(); } +/// All post sort type items (default + top) combined. List> allPostSortTypeItems = [...getDefaultPostSortTypeItems(), ...getTopPostSortTypeItems()]; -class SortPicker extends BottomSheetListPicker { - /// The account that triggered the sort picker. +// ============================================================================ +// Comment Sort Type Items +// ============================================================================ + +/// Returns the sort type items for comments +List> getCommentSortTypeItems({Account? account}) { + final l10n = GlobalContext.l10n; + final platform = account?.platform; + + List> commentSortTypeItems = [ + ListPickerItem( + payload: CommentSortType.hot, + icon: Icons.local_fire_department, + label: l10n.hot, + ), + ListPickerItem( + payload: CommentSortType.top, + icon: Icons.military_tech, + label: l10n.top, + ), + ListPickerItem( + payload: CommentSortType.controversial, + icon: Icons.warning_rounded, + label: l10n.controversial, + ), + ListPickerItem( + payload: CommentSortType.new_, + icon: Icons.auto_awesome_rounded, + label: l10n.new_, + ), + ListPickerItem( + payload: CommentSortType.old, + icon: Icons.access_time_outlined, + label: l10n.old, + ), + ]; + + if (platform == null) return commentSortTypeItems; + + // Only return the sort types that are available for the platform (or all platforms). + return commentSortTypeItems.where((item) => item.payload.platform == platform || item.payload.platform == null).toList(); +} + +// ============================================================================ +// Search Sort Type Items +// ============================================================================ + +/// Returns the "Top" sort type items for search (TopHour, TopDay, etc.) +List> getTopSearchSortTypeItems({Account? account}) { + final l10n = GlobalContext.l10n; + final platform = account?.platform; + + List> topSearchSortTypeItems = [ + ListPickerItem( + payload: SearchSortType.topHour, + icon: Icons.check_box_outline_blank, + label: l10n.topHour, + ), + ListPickerItem( + payload: SearchSortType.topSixHour, + icon: Icons.calendar_view_month, + label: l10n.topSixHour, + ), + ListPickerItem( + payload: SearchSortType.topTwelveHour, + icon: Icons.calendar_view_week, + label: l10n.topTwelveHour, + ), + ListPickerItem( + payload: SearchSortType.topDay, + icon: Icons.today, + label: l10n.topDay, + ), + ListPickerItem( + payload: SearchSortType.topWeek, + icon: Icons.view_week_sharp, + label: l10n.topWeek, + ), + ListPickerItem( + payload: SearchSortType.topMonth, + icon: Icons.calendar_month, + label: l10n.topMonth, + ), + ListPickerItem( + payload: SearchSortType.topThreeMonths, + icon: Icons.calendar_month_outlined, + label: l10n.topThreeMonths, + ), + ListPickerItem( + payload: SearchSortType.topSixMonths, + icon: Icons.calendar_today_outlined, + label: l10n.topSixMonths, + ), + ListPickerItem( + payload: SearchSortType.topNineMonths, + icon: Icons.calendar_view_day_outlined, + label: l10n.topNineMonths, + ), + ListPickerItem( + payload: SearchSortType.topYear, + icon: Icons.calendar_today, + label: l10n.topYear, + ), + ListPickerItem( + payload: SearchSortType.topAll, + icon: Icons.military_tech, + label: l10n.topAll, + ), + ]; + + if (platform == null) return topSearchSortTypeItems; + + // Only return the sort types that are available for the platform (or all platforms). + return topSearchSortTypeItems.where((item) => item.payload.platform == platform || item.payload.platform == null).toList(); +} + +/// Returns the default (non-Top) sort type items for search +List> getDefaultSearchSortTypeItems({Account? account}) { + final l10n = GlobalContext.l10n; + final platform = account?.platform; + + List> defaultSearchSortTypeItems = [ + ListPickerItem( + payload: SearchSortType.new_, + icon: Icons.auto_awesome_rounded, + label: l10n.new_, + ), + ListPickerItem( + payload: SearchSortType.old, + icon: Icons.access_time_outlined, + label: l10n.old, + ), + ListPickerItem( + payload: SearchSortType.controversial, + icon: Icons.warning_rounded, + label: l10n.controversial, + ), + ]; + + if (platform == null) return defaultSearchSortTypeItems; + + // Only return the sort types that are available for the platform (or all platforms). + return defaultSearchSortTypeItems.where((item) => item.payload.platform == platform || item.payload.platform == null).toList(); +} + +/// All search sort type items (default + top) combined. +List> allSearchSortTypeItems = [...getDefaultSearchSortTypeItems(), ...getTopSearchSortTypeItems()]; + +// ============================================================================ +// Unified Sort Picker Widget +// ============================================================================ + +/// A unified sort picker that works with PostSortType, CommentSortType, and SearchSortType. +/// +/// Usage: +/// ```dart +/// // For posts +/// SortPicker( +/// title: 'Sort Options', +/// onSelect: (selected) => print(selected.payload), +/// previouslySelected: PostSortType.hot, +/// ) +/// +/// // For comments +/// SortPicker( +/// title: 'Sort Options', +/// onSelect: (selected) => print(selected.payload), +/// previouslySelected: CommentSortType.hot, +/// ) +/// +/// // For search +/// SortPicker( +/// title: 'Sort Options', +/// onSelect: (selected) => print(selected.payload), +/// previouslySelected: SearchSortType.topYear, +/// ) +/// ``` +class SortPicker extends BottomSheetListPicker { + /// The account that triggered the sort picker. Used to filter sort options by platform. final Account? account; /// Create a picker which allows selecting a valid sort type. @@ -140,22 +326,37 @@ class SortPicker extends BottomSheetListPicker { required super.onSelect, required super.title, super.previouslySelected, - }) : super(items: getDefaultPostSortTypeItems(account: account)); + }) : super(items: _getItems(account)); + + /// Get the appropriate items based on the generic type T. + static List> _getItems(Account? account) { + if (T == PostSortType) { + return getDefaultPostSortTypeItems(account: account) as List>; + } else if (T == CommentSortType) { + return getCommentSortTypeItems(account: account) as List>; + } else if (T == SearchSortType) { + return getDefaultSearchSortTypeItems(account: account) as List>; + } + throw ArgumentError('Unsupported sort type: $T. Must be PostSortType, CommentSortType, or SearchSortType.'); + } @override - State createState() => _SortPickerState(); + State createState() => _SortPickerState(); } -class _SortPickerState extends State { +class _SortPickerState extends State> { bool topSelected = false; + /// Whether this sort type has a "Top" submenu (only PostSortType and SearchSortType have this). + bool get hasTopSubmenu => T == PostSortType || T == SearchSortType; + @override Widget build(BuildContext context) { return SingleChildScrollView( child: AnimatedSize( duration: const Duration(milliseconds: 400), curve: Curves.easeInOutCubicEmphasized, - child: topSelected ? topSortPicker() : defaultSortPicker(), + child: hasTopSubmenu && topSelected ? topSortPicker() : defaultSortPicker(), ), ); } @@ -180,14 +381,15 @@ class _SortPickerState extends State { shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), children: [ - ..._generateList(getDefaultPostSortTypeItems(account: widget.account), theme), - PickerItem( - label: l10n.top, - icon: Icons.military_tech, - onSelected: () => setState(() => topSelected = true), - isSelected: getTopPostSortTypeItems(account: widget.account).map((item) => item.payload).contains(widget.previouslySelected), - trailingIcon: Icons.chevron_right, - ) + ..._generateList(_getDefaultItems(), theme), + if (hasTopSubmenu) + PickerItem( + label: l10n.top, + icon: Icons.military_tech, + onSelected: () => setState(() => topSelected = true), + isSelected: _isTopItemSelected(), + trailingIcon: Icons.chevron_right, + ) ], ), const SizedBox(height: 16.0), @@ -238,14 +440,42 @@ class _SortPickerState extends State { ListView( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - children: [..._generateList(getTopPostSortTypeItems(account: widget.account), theme)], + children: [..._generateList(_getTopItems(), theme)], ), const SizedBox(height: 16.0), ], ); } - List _generateList(List> items, ThemeData theme) { + /// Get the default (non-Top) items for the current sort type. + List> _getDefaultItems() { + if (T == PostSortType) { + return getDefaultPostSortTypeItems(account: widget.account) as List>; + } else if (T == CommentSortType) { + return getCommentSortTypeItems(account: widget.account) as List>; + } else if (T == SearchSortType) { + return getDefaultSearchSortTypeItems(account: widget.account) as List>; + } + return []; + } + + /// Get the "Top" items for the current sort type. + List> _getTopItems() { + if (T == PostSortType) { + return getTopPostSortTypeItems(account: widget.account) as List>; + } else if (T == SearchSortType) { + return getTopSearchSortTypeItems(account: widget.account) as List>; + } + return []; + } + + /// Check if a "Top" item is currently selected. + bool _isTopItemSelected() { + final topItems = _getTopItems(); + return topItems.map((item) => item.payload).contains(widget.previouslySelected); + } + + List _generateList(List> items, ThemeData theme) { return items .map((item) => PickerItem( label: item.label, diff --git a/lib/src/shared/utils/constants.dart b/lib/src/shared/utils/constants.dart index 0a75293bb..7fcccf36e 100644 --- a/lib/src/shared/utils/constants.dart +++ b/lib/src/shared/utils/constants.dart @@ -6,13 +6,14 @@ import 'package:thunder/src/core/enums/comment_sort_type.dart'; import 'package:thunder/src/core/enums/enums.dart'; import 'package:thunder/src/core/enums/nested_comment_indicator.dart'; import 'package:thunder/src/core/enums/post_sort_type.dart'; +import 'package:thunder/src/core/enums/search_sort_type.dart'; import 'package:thunder/src/features/post/post.dart'; const FeedListType DEFAULT_LISTING_TYPE = FeedListType.all; const PostSortType DEFAULT_POST_SORT_TYPE = PostSortType.hot; -const PostSortType DEFAULT_SEARCH_POST_SORT_TYPE = PostSortType.topYear; +const SearchSortType DEFAULT_SEARCH_SORT_TYPE = SearchSortType.topYear; const CommentSortType DEFAULT_COMMENT_SORT_TYPE = CommentSortType.top; From 7909b4ced14ad90da42156d4c1824d96c1332615 Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su Date: Tue, 13 Jan 2026 17:45:03 -0800 Subject: [PATCH 3/3] feat: limit search filters on per search type basis --- .../search/presentation/bloc/search_bloc.dart | 5 ++++- .../widgets/search_filters_row.dart | 17 +++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/lib/src/features/search/presentation/bloc/search_bloc.dart b/lib/src/features/search/presentation/bloc/search_bloc.dart index 050dbc203..3a33d18c9 100644 --- a/lib/src/features/search/presentation/bloc/search_bloc.dart +++ b/lib/src/features/search/presentation/bloc/search_bloc.dart @@ -73,7 +73,10 @@ class SearchBloc extends Bloc { userRepository = UserRepositoryImpl(account: account); instanceRepository = InstanceRepositoryImpl(account: account); - on(_onSearchReset); + on( + _onSearchReset, + transformer: restartable(), + ); on( _onSearchStarted, // Use restartable here so that a long search can essentially be "canceled" by a new one. diff --git a/lib/src/features/search/presentation/widgets/search_filters_row.dart b/lib/src/features/search/presentation/widgets/search_filters_row.dart index 3ce3333fc..65c916de3 100644 --- a/lib/src/features/search/presentation/widgets/search_filters_row.dart +++ b/lib/src/features/search/presentation/widgets/search_filters_row.dart @@ -8,6 +8,7 @@ import 'package:thunder/src/app/utils/global_context.dart'; import 'package:thunder/src/core/enums/enums.dart'; import 'package:thunder/src/core/enums/full_name.dart'; import 'package:thunder/src/core/enums/meta_search_type.dart'; +import 'package:thunder/src/core/enums/threadiverse_platform.dart'; import 'package:thunder/src/features/account/account.dart'; import 'package:thunder/src/features/community/community.dart'; import 'package:thunder/src/features/search/search.dart'; @@ -103,17 +104,21 @@ class _SearchFiltersRowState extends State { if (widget.community == null) ...[ const SizedBox(width: 10), _FeedTypeChip(onSearch: widget.onSearch), + if (!(state.searchType == MetaSearchType.users || state.searchType == MetaSearchType.communities)) ...[ + const SizedBox(width: 10), + _CommunityFilterChip( + account: widget.account, + onSearch: widget.onSearch, + ), + ] + ], + if (!(state.searchType == MetaSearchType.users || state.searchType == MetaSearchType.communities || widget.account.platform == ThreadiversePlatform.piefed)) ...[ const SizedBox(width: 10), - _CommunityFilterChip( + _CreatorFilterChip( account: widget.account, onSearch: widget.onSearch, ), ], - const SizedBox(width: 10), - _CreatorFilterChip( - account: widget.account, - onSearch: widget.onSearch, - ), ], ], ),