Quick prototyping of new explorer screen

merge-requests/67/head
Hank Grabowski 2024-12-21 00:08:29 -05:00
rodzic dd40c9a577
commit 841a7ea7b6
3 zmienionych plików z 359 dodań i 3 usunięć

Wyświetl plik

@ -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) {

Wyświetl plik

@ -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(

Wyświetl plik

@ -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<void> 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<SearchTypes>(
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'));
}
});
}
}