relatica/lib/controls/search_panel.dart

313 wiersze
9.7 KiB
Dart

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<SearchPanel> createState() => SearchPanelState();
}
class SearchPanelState extends ConsumerState<SearchPanel> {
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<void> 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<SearchTypes>(
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'));
}
});
}
}