diff --git a/lib/controls/media_attachment_viewer_control.dart b/lib/controls/media_attachment_viewer_control.dart index 27ec270..9a813b6 100644 --- a/lib/controls/media_attachment_viewer_control.dart +++ b/lib/controls/media_attachment_viewer_control.dart @@ -30,7 +30,7 @@ class _MediaAttachmentViewerControlState @override Widget build(BuildContext context) { final item = widget.attachments[widget.index]; - final width = widget.width! * 0.9; + final width = widget.width == null ? null : widget.width! * 0.9; final height = widget.height; openMediaScreenCallback() { Navigator.push(context, MaterialPageRoute(builder: (context) { diff --git a/lib/routes.dart b/lib/routes.dart index c10147e..d0dc7e9 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -9,6 +9,7 @@ import 'screens/circle_management_screen.dart'; import 'screens/contacts_screen.dart'; import 'screens/disable_focus_mode_screen.dart'; import 'screens/editor.dart'; +import 'screens/explore_screen.dart'; import 'screens/filter_editor_screen.dart'; import 'screens/filters_screen.dart'; import 'screens/follow_request_adjudication_screen.dart'; @@ -23,7 +24,6 @@ import 'screens/message_threads_browser_screen.dart'; import 'screens/messages_new_thread.dart'; import 'screens/notifications_screen.dart'; import 'screens/post_screen.dart'; -import 'screens/search_screen.dart'; import 'screens/settings_screen.dart'; import 'screens/sign_in.dart'; import 'screens/splash.dart'; @@ -296,7 +296,7 @@ final routes = [ name: ScreenPaths.search, pageBuilder: (context, state) => NoTransitionPage( name: ScreenPaths.search, - child: const SearchScreen(), + child: const ExploreScreen(), ), ), GoRoute( diff --git a/lib/screens/explore_screen.dart b/lib/screens/explore_screen.dart new file mode 100644 index 0000000..d02a734 --- /dev/null +++ b/lib/screens/explore_screen.dart @@ -0,0 +1,356 @@ +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 '../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 '../models/auth/profile.dart'; +import '../models/connection.dart'; +import '../models/networking/paging_data.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/networking/friendica_search_client_services.dart'; +import '../riverpod_controllers/networking/network_status_services.dart'; +import '../routes.dart'; +import '../utils/snackbar_builder.dart'; + +class ExploreScreen extends ConsumerWidget { + const ExploreScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return DefaultTabController( + length: 2, + child: Scaffold( + drawer: const StandardAppDrawer(skipPopDismiss: false), + appBar: AppBar( + leading: const CurrentProfileButton(), + title: TabBar( + tabs: [Tab(icon: Icon(Icons.search)), Tab(icon: Icon(Icons.bolt))], + ), + ), + body: SafeArea( + child: TabBarView(children: [_SearchPanel(), Icon(Icons.bolt)]), + ), + bottomNavigationBar: const AppBottomNavBar( + currentButton: NavBarButtons.search, + ), + ), + ); + } +} + +class _SearchPanel extends ConsumerStatefulWidget { + @override + ConsumerState<_SearchPanel> createState() => _SearchPanelState(); +} + +class _SearchPanelState extends ConsumerState<_SearchPanel> { + static const limit = 50; + static final _logger = Logger('SearchPanel'); + var searchTextController = TextEditingController(); + var searchType = SearchTypes.statusesText; + PagingData nextPage = PagingData(limit: limit); + var searchResult = SearchResults.empty(); + Profile? profileOfSearchRequest; + + 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 { + profileOfSearchRequest = profile; + if (reset) { + nextPage = PagingData(limit: limit); + searchResult = SearchResults.empty(); + } + + final result = await ref.watch(searchResultsProvider( + profile, searchType, searchTextController.text, nextPage) + .future); + result.match( + onSuccess: (result) { + searchResult = reset ? result.data : searchResult.merge(result.data); + nextPage = result.next ?? genNextPageData(); + profileOfSearchRequest = profile; + }, + onError: (error) => + buildSnackbar(context, 'Error getting search result: $error'), + ); + } + + clearSearchResults() { + setState(() { + searchResult = SearchResults.empty(); + searchTextController.text = ''; + }); + } + + @override + Widget build(BuildContext context) { + _logger.finer('Build'); + final profile = ref.watch(activeProfileProvider); + final searching = ref.watch(searchLoadingStatusProvider(profile)); + late Widget body; + + if (profile != profileOfSearchRequest) { + body = buildEmptyResult(); + } else if (searchResult.isEmpty && searching) { + body = Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Searching for ${searchType.toLabel()} on: ${searchTextController.text}', + ), + ], + ), + ); + } else { + body = ResponsiveMaxWidth(child: buildResultBody(profile)); + } + + return RefreshIndicator( + onRefresh: () async { + if (searching) { + return; + } + updateSearchResults(profile); + return; + }, + child: Column( + children: [ + Row( + children: [ + const SizedBox(width: 50.0, child: CurrentProfileButton()), + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + controller: searchTextController, + onSubmitted: (value) { + searchTextController.text = 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.surface, + ), + 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), + ], + ), + ); + } + + Widget buildResultBody(Profile profile) { + _logger.finer('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(profile, 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(profile, 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(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 { + 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')); + } + }); + } +}