import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; import '../models/auth/profile.dart'; import '../models/connection.dart'; import '../models/search_results.dart'; import '../models/search_types.dart'; import '../models/timeline_entry.dart'; import '../riverpod_controllers/account_services.dart'; import '../riverpod_controllers/connection_manager_services.dart'; import '../riverpod_controllers/entry_tree_item_services.dart'; import '../riverpod_controllers/search_result_services.dart'; import '../routes.dart'; import '../utils/snackbar_builder.dart'; import 'async_value_widget.dart'; import 'error_message_widget.dart'; import 'image_control.dart'; import 'responsive_max_width.dart'; import 'search_result_status_control.dart'; class SearchPanel extends ConsumerStatefulWidget { final String initialSearchText; final SearchTypes initialType; final bool showSearchbar; const SearchPanel({ super.key, this.initialSearchText = '', this.initialType = SearchTypes.statusesText, this.showSearchbar = true, }); @override ConsumerState createState() => SearchPanelState(); } class SearchPanelState extends ConsumerState { static final _logger = Logger('SearchPanel'); var searchTextController = TextEditingController(); var searchType = SearchTypes.statusesText; @override void initState() { super.initState(); searchTextController.text = widget.initialSearchText; searchType = widget.initialType; } clearSearchResults() { setState(() { searchTextController.text = ''; }); } Future updateSearchResults(Profile profile, bool reset) async { setState(() {}); await ref .read(searchResultsManagerProvider( profile, searchType, searchTextController.text) .notifier) .updateSearchResults(reset: reset); } @override Widget build(BuildContext context) { _logger.finer('Build'); final profile = ref.watch(activeProfileProvider); final searchResult = ref.watch(searchResultsManagerProvider( profile, searchType, searchTextController.text)); _logger.finer('Search Result = $searchResult'); final searching = switch (searchResult) { AsyncData() => false, AsyncError() => false, _ => true }; final body = AsyncValueWidget(searchResult, valueBuilder: (_, __, searchResult) => searchResult.fold( onSuccess: (results) => ResponsiveMaxWidth(child: buildResultBody(profile, results)), onError: (error) => ErrorMessageWidget(message: error.message))); final searchbar = Row( children: [ Expanded( child: Padding( padding: const EdgeInsets.all(8.0), child: TextField( controller: searchTextController, onSubmitted: (value) { searchTextController.text = value; updateSearchResults(profile, true); }, onTapOutside: (event) { FocusManager.instance.primaryFocus?.unfocus(); }, decoration: InputDecoration( labelText: searchType == SearchTypes.directLink ? 'URL' : '${searchType.toLabel()} Search Text', alignLabelWithHint: true, suffixIcon: IconButton( onPressed: clearSearchResults, icon: const Icon(Icons.clear), ), border: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.surface, ), borderRadius: BorderRadius.circular(5.0), ), ), ), ), ), IconButton( onPressed: () { updateSearchResults(profile, true); }, icon: const Icon(Icons.search), ), PopupMenuButton( initialValue: searchType, onSelected: (type) { setState(() { searchType = type; }); }, itemBuilder: (_) => SearchTypes.values .map((e) => PopupMenuItem( value: e, child: Text( e.toLabel(), ))) .toList()), ], ); return RefreshIndicator( onRefresh: () async { if (searching) { return; } await updateSearchResults(profile, true); }, child: Column( children: [ if (widget.showSearchbar) searchbar, Expanded(child: body), ], ), ); } Widget buildResultBody(Profile profile, SearchResults searchResult) { _logger.finer('Building search result body with: $searchResult'); switch (searchType) { case SearchTypes.hashTag: return buildHashtagResultWidget(profile, searchResult); case SearchTypes.account: return buildAccountResultWidget(profile, searchResult); case SearchTypes.statusesText: return buildStatusResultWidget(profile, searchResult); case SearchTypes.directLink: return buildDirectLinkResult(profile, searchResult); } } Widget buildHashtagResultWidget(Profile profile, SearchResults searchResult) { final hashtags = searchResult.hashtags; if (hashtags.isEmpty) { return buildEmptyResult(); } return ListView.builder( physics: const AlwaysScrollableScrollPhysics(), itemBuilder: (context, index) { if (index == hashtags.length) { return TextButton( onPressed: () { updateSearchResults(profile, false); }, child: const Text('Load more results'), ); } return ListTile( title: Text(hashtags[index]), onTap: () => context.push( '${ScreenPaths.tagView}/${hashtags[index]}', ), ); }, itemCount: hashtags.length + 1, ); } Widget buildAccountResultWidget(Profile profile, SearchResults searchResult) { final accounts = searchResult.accounts; if (accounts.isEmpty) { return buildEmptyResult(); } return ListView.builder( physics: const AlwaysScrollableScrollPhysics(), itemBuilder: (_, index) { if (index == accounts.length) { return TextButton( onPressed: () { updateSearchResults(profile, false); }, child: const Text('Load more results'), ); } return buildConnectionListTile(profile, accounts[index]); }, itemCount: accounts.length + 1, ); } Widget buildStatusResultWidget(Profile profile, SearchResults searchResult) { final statuses = searchResult.statuses; if (statuses.isEmpty) { return buildEmptyResult(); } return ListView.builder( physics: const AlwaysScrollableScrollPhysics(), itemBuilder: (context, index) { if (index == statuses.length) { return TextButton( onPressed: () { updateSearchResults(profile, false); }, child: const Text('Load more results'), ); } return buildStatusListTile(profile, statuses[index]); }, itemCount: statuses.length + 1, ); } Widget buildDirectLinkResult(Profile profile, SearchResults searchResult) { if (searchResult.isEmpty) { return buildEmptyResult(); } return ListView(physics: const AlwaysScrollableScrollPhysics(), children: [ if (searchResult.statuses.isNotEmpty) buildStatusListTile(profile, searchResult.statuses.first), if (searchResult.accounts.isNotEmpty) buildConnectionListTile(profile, searchResult.accounts.first), ]); } Widget buildEmptyResult() { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text(searchTextController.text.isEmpty ? 'Type search text to search' : 'No results for ${searchType.toLabel()} search on: ${searchTextController.text}'), ], ), ); } Widget buildConnectionListTile(Profile profile, Connection connection) { return ListTile( onTap: () async { buildSnackbar( context, 'Fetching profile data to open ${connection.name}'); await ref .read(connectionByIdProvider(profile, connection.id)) .withErrorAsync((_) => ref .read(connectionModifierProvider(profile, connection).notifier) .fullRefresh()); if (mounted) { context.pushNamed(ScreenPaths.userProfile, pathParameters: {'id': connection.id}); } }, leading: ImageControl( imageUrl: connection.avatarUrl.toString(), iconOverride: const Icon(Icons.person), width: 32.0, onTap: () => context.pushNamed(ScreenPaths.userProfile, pathParameters: {'id': connection.id}), ), title: Text('${connection.name} (${connection.handle})'), ); } Widget buildStatusListTile(Profile profile, TimelineEntry status) { return SearchResultStatusControl(status, () async { final result = await ref .read(timelineUpdaterProvider(profile).notifier) .refreshStatusChain(status.id); if (context.mounted) { result.match( onSuccess: (entry) => context.push('/post/view/${entry.id}/${status.id}'), onError: (error) => buildSnackbar(context, 'Error getting post: $error')); } }); } }