import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; import 'package:provider/provider.dart'; import '../controls/app_bottom_nav_bar.dart'; import '../controls/current_profile_button.dart'; import '../controls/image_control.dart'; import '../controls/responsive_max_width.dart'; import '../controls/search_result_status_control.dart'; import '../controls/standard_app_drawer.dart'; import '../friendica_client/friendica_client.dart'; import '../friendica_client/paging_data.dart'; import '../globals.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 '../routes.dart'; import '../services/auth_service.dart'; import '../services/connections_manager.dart'; import '../services/entry_manager_service.dart'; import '../services/network_status_service.dart'; import '../utils/active_profile_selector.dart'; import '../utils/snackbar_builder.dart'; class SearchScreen extends StatefulWidget { const SearchScreen({super.key}); @override State createState() => _SearchScreenState(); } class _SearchScreenState extends State { static const limit = 50; static final _logger = Logger('$SearchScreen'); var searchText = ''; var searchType = SearchTypes.statusesText; var searching = false; PagingData nextPage = PagingData(limit: limit); var searchResult = SearchResults.empty(); PagingData genNextPageData() { late final int offset; switch (searchType) { case SearchTypes.hashTag: offset = searchResult.hashtags.length; break; case SearchTypes.account: offset = searchResult.accounts.length; break; case SearchTypes.statusesText: offset = searchResult.statuses.length; break; case SearchTypes.directLink: offset = 0; break; } return PagingData(limit: limit, offset: offset); } Future updateSearchResults(Profile profile, {bool reset = true}) async { print('Starting update'); if (reset) { nextPage = PagingData(limit: limit); searchResult = SearchResults.empty(); } setState(() { searching = true; }); print('Search $searchType on $searchText'); final result = await SearchClient(profile).search(searchType, searchText, nextPage); result.match( onSuccess: (result) { searchResult = reset ? result.data : searchResult.merge(result.data); nextPage = result.next ?? genNextPageData(); }, onError: (error) => buildSnackbar(context, 'Error getting search result: $error'), ); setState(() { searching = false; }); print('Ending update'); } @override Widget build(BuildContext context) { _logger.info('Build'); final nss = getIt(); final profileService = context.watch(); final profile = profileService.currentProfile; late Widget body; if (searchResult.isEmpty && searching) { body = Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( 'Searching for ${searchType.toLabel()} on: $searchText', ), ], ), ); } else { body = ResponsiveMaxWidth(child: buildResultBody(profile)); } return Scaffold( drawer: const StandardAppDrawer(skipPopDismiss: true), body: SafeArea( child: RefreshIndicator( onRefresh: () async { if (nss.searchLoadingStatus.value) { return; } updateSearchResults(profile); return; }, child: Column( children: [ Row( children: [ SizedBox( width: 50.0, child: buildCurrentProfileButton(context)!), Expanded( child: Padding( padding: const EdgeInsets.all(8.0), child: TextField( onChanged: (value) { searchText = value; }, onSubmitted: (value) { searchText = value; updateSearchResults(profile); }, onTapOutside: (event) { FocusManager.instance.primaryFocus?.unfocus(); }, decoration: InputDecoration( labelText: searchType == SearchTypes.directLink ? 'URL' : '${searchType.toLabel()} Search Text', alignLabelWithHint: true, border: OutlineInputBorder( borderSide: BorderSide( color: Theme.of(context).colorScheme.background, ), borderRadius: BorderRadius.circular(5.0), ), ), ), ), ), IconButton( onPressed: () { updateSearchResults(profile); }, 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()), ], ), if (searching) const LinearProgressIndicator(), Expanded(child: body), ], ), ), ), bottomNavigationBar: const AppBottomNavBar( currentButton: NavBarButtons.search, ), ); } Widget buildResultBody(Profile profile) { _logger.fine('Building search result body with: $searchResult'); switch (searchType) { case SearchTypes.hashTag: return buildHashtagResultWidget(profile); case SearchTypes.account: return buildAccountResultWidget(profile); case SearchTypes.statusesText: return buildStatusResultWidget(profile); case SearchTypes.directLink: return buildDirectLinkResult(profile); } } Widget buildHashtagResultWidget(Profile profile) { 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, reset: false); }, child: const Text('Load more results'), ); } return ListTile( title: Text(hashtags[index]), ); }, itemCount: hashtags.length + 1, ); } Widget buildAccountResultWidget(Profile profile) { 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, reset: false); }, child: const Text('Load more results'), ); } return buildConnectionListTile(accounts[index]); }, itemCount: accounts.length + 1, ); } Widget buildStatusResultWidget(Profile profile) { 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, reset: false); }, child: const Text('Load more results'), ); } return buildStatusListTile(statuses[index]); }, itemCount: statuses.length + 1, ); } Widget buildDirectLinkResult(Profile profile) { if (searchResult.isEmpty) { return buildEmptyResult(); } return ListView(physics: const AlwaysScrollableScrollPhysics(), children: [ if (searchResult.statuses.isNotEmpty) buildStatusListTile(searchResult.statuses.first), if (searchResult.accounts.isNotEmpty) buildConnectionListTile(searchResult.accounts.first), ]); } Widget buildEmptyResult() { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text(searchText.isEmpty ? 'Type search text to search' : 'No results for ${searchType.toLabel()} search on: $searchText'), ], ), ); } Widget buildConnectionListTile(Connection connection) { return ListTile( onTap: () async { await getIt>() .activeEntry .andThenSuccessAsync((cm) async { final existingData = cm.getById(connection.id); if (existingData.isFailure) { await cm.fullRefresh(connection); } }); if (context.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(TimelineEntry status) { return SearchResultStatusControl(status, () async { final result = await getIt>() .activeEntry .andThenAsync((em) async => em.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')); } }); } }