2023-03-22 04:16:23 +00:00
|
|
|
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';
|
2023-04-13 14:30:09 +00:00
|
|
|
import '../controls/responsive_max_width.dart';
|
2023-03-22 04:16:23 +00:00
|
|
|
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 {
|
|
|
|
@override
|
|
|
|
State<SearchScreen> createState() => _SearchScreenState();
|
|
|
|
}
|
|
|
|
|
|
|
|
class _SearchScreenState extends State<SearchScreen> {
|
|
|
|
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 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 {
|
|
|
|
print('Starting update');
|
|
|
|
if (reset) {
|
|
|
|
nextPage = PagingData(limit: limit);
|
2023-03-22 04:35:38 +00:00
|
|
|
searchResult = SearchResults.empty();
|
2023-03-22 04:16:23 +00:00
|
|
|
}
|
2023-03-22 04:35:38 +00:00
|
|
|
setState(() {
|
|
|
|
searching = true;
|
|
|
|
});
|
|
|
|
|
2023-03-22 04:16:23 +00:00
|
|
|
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<NetworkStatusService>();
|
|
|
|
final profileService = context.watch<AccountsService>();
|
|
|
|
final profile = profileService.currentProfile;
|
|
|
|
late Widget body;
|
|
|
|
|
2023-03-22 04:35:38 +00:00
|
|
|
if (searchResult.isEmpty && searching) {
|
2023-03-22 04:16:23 +00:00
|
|
|
body = Center(
|
|
|
|
child: Column(
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
children: [
|
|
|
|
Text(
|
|
|
|
'Searching for ${searchType.toLabel()} on: $searchText',
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
);
|
|
|
|
} else {
|
2023-04-13 14:30:09 +00:00
|
|
|
body = ResponsiveMaxWidth(child: buildResultBody(profile));
|
2023-03-22 04:16:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return Scaffold(
|
|
|
|
drawer: 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);
|
|
|
|
},
|
2023-03-23 15:25:05 +00:00
|
|
|
onTapOutside: (event) {
|
|
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
|
|
|
},
|
2023-03-22 04:16:23 +00:00
|
|
|
decoration: InputDecoration(
|
|
|
|
labelText: searchType == SearchTypes.directLink
|
|
|
|
? 'URL'
|
|
|
|
: '${searchType.toLabel()} Search Text',
|
|
|
|
alignLabelWithHint: true,
|
|
|
|
border: OutlineInputBorder(
|
|
|
|
borderSide: BorderSide(
|
|
|
|
color: Theme.of(context).backgroundColor,
|
|
|
|
),
|
|
|
|
borderRadius: BorderRadius.circular(5.0),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
2023-04-13 14:30:09 +00:00
|
|
|
IconButton(
|
2023-03-22 04:16:23 +00:00
|
|
|
onPressed: () {
|
|
|
|
updateSearchResults(profile);
|
|
|
|
},
|
2023-04-13 14:30:09 +00:00
|
|
|
icon: const Icon(Icons.search),
|
2023-03-22 04:16:23 +00:00
|
|
|
),
|
|
|
|
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),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
2023-03-22 13:43:17 +00:00
|
|
|
bottomNavigationBar: const AppBottomNavBar(
|
|
|
|
currentButton: NavBarButtons.search,
|
2023-03-22 04:16:23 +00:00
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget buildResultBody(Profile profile) {
|
2023-03-22 04:35:38 +00:00
|
|
|
_logger.fine('Building search result body with: $searchResult');
|
2023-03-22 04:16:23 +00:00
|
|
|
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<ActiveProfileSelector<ConnectionsManager>>()
|
|
|
|
.activeEntry
|
2023-03-22 13:24:14 +00:00
|
|
|
.andThenSuccessAsync((cm) async {
|
|
|
|
final existingData = cm.getById(connection.id);
|
|
|
|
if (existingData.isFailure) {
|
|
|
|
await cm.fullRefresh(connection);
|
|
|
|
}
|
|
|
|
});
|
2023-03-22 04:16:23 +00:00
|
|
|
if (context.mounted) {
|
|
|
|
context.pushNamed(ScreenPaths.userProfile,
|
|
|
|
params: {'id': connection.id});
|
|
|
|
}
|
|
|
|
},
|
|
|
|
leading: ImageControl(
|
|
|
|
imageUrl: connection.avatarUrl.toString(),
|
|
|
|
iconOverride: const Icon(Icons.person),
|
|
|
|
width: 32.0,
|
|
|
|
onTap: () => context
|
|
|
|
.pushNamed(ScreenPaths.userProfile, params: {'id': connection.id}),
|
|
|
|
),
|
|
|
|
title: Text('${connection.name} (${connection.handle})'),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget buildStatusListTile(TimelineEntry status) {
|
|
|
|
return SearchResultStatusControl(status, () async {
|
|
|
|
final result = await getIt<ActiveProfileSelector<EntryManagerService>>()
|
|
|
|
.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'));
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|