Initial cut of the search page

codemagic-setup
Hank Grabowski 2023-03-22 00:16:23 -04:00
rodzic fb24bc584f
commit 7be5176126
11 zmienionych plików z 688 dodań i 2 usunięć

Wyświetl plik

@ -6,7 +6,6 @@ import 'package:provider/provider.dart';
import '../routes.dart';
import '../services/notifications_manager.dart';
import '../utils/active_profile_selector.dart';
import '../utils/snackbar_builder.dart';
enum NavBarButtons {
timelines,
@ -51,7 +50,7 @@ class AppBottomNavBar extends StatelessWidget {
context.pushNamed(ScreenPaths.contacts);
break;
case NavBarButtons.search:
buildSnackbar(context, 'Search screen coming soon...');
context.pushNamed(ScreenPaths.search);
break;
}
},

Wyświetl plik

@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
import 'package:logging/logging.dart';
import '../models/timeline_entry.dart';
import '../utils/clipboard_utils.dart';
import '../utils/url_opening_utils.dart';
import 'media_attachment_viewer_control.dart';
import 'padding.dart';
import 'timeline/link_preview_control.dart';
import 'timeline/status_header_control.dart';
class SearchResultStatusControl extends StatefulWidget {
static final _logger = Logger('$SearchResultStatusControl');
final TimelineEntry status;
final Future Function() goToPostFunction;
const SearchResultStatusControl(this.status, this.goToPostFunction,
{super.key});
@override
State<SearchResultStatusControl> createState() =>
_SearchResultStatusControlState();
}
class _SearchResultStatusControlState extends State<SearchResultStatusControl> {
var showContent = false;
TimelineEntry get status => widget.status;
@override
void initState() {
showContent = widget.status.spoilerText.isEmpty;
}
@override
Widget build(BuildContext context) {
SearchResultStatusControl._logger
.finest('Building ${widget.status.toShortString()}');
const otherPadding = 8.0;
final body = Container(
decoration: BoxDecoration(
color: Theme.of(context).dialogBackgroundColor,
border: Border.all(width: 0.5),
borderRadius: BorderRadius.circular(5.0),
boxShadow: [
BoxShadow(
color: Theme.of(context).dividerColor,
blurRadius: 2,
offset: Offset(4, 4),
spreadRadius: 0.1,
blurStyle: BlurStyle.normal,
)
],
),
child: Padding(
padding: const EdgeInsets.all(5.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: StatusHeaderControl(
entry: widget.status,
showIsCommentText: true,
),
),
buildMenuControl(context),
],
),
const VerticalPadding(
height: 5,
),
if (status.spoilerText.isNotEmpty)
TextButton(
onPressed: () {
setState(() {
showContent = !showContent;
});
},
child: Text(
'Content Summary: ${status.spoilerText} (Click to ${showContent ? "Hide" : "Show"}}')),
if (showContent) ...[
buildBody(context),
const VerticalPadding(
height: 5,
),
if (status.linkPreviewData != null)
LinkPreviewControl(preview: status.linkPreviewData!),
buildMediaBar(context),
],
const VerticalPadding(
height: 5,
),
const VerticalPadding(
height: 5,
),
],
),
),
);
return Padding(
padding: const EdgeInsets.only(
left: otherPadding,
right: otherPadding,
top: otherPadding,
bottom: otherPadding,
),
child: body,
);
}
Widget buildBody(BuildContext context) {
return HtmlWidget(
widget.status.body,
onTapUrl: (url) async {
return await openUrlStringInSystembrowser(context, url, 'link');
},
);
}
Widget buildMediaBar(BuildContext context) {
final items = widget.status.mediaAttachments;
if (items.isEmpty) {
return const SizedBox();
}
return SizedBox(
height: 250.0,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
return MediaAttachmentViewerControl(
attachments: items,
index: index,
);
},
separatorBuilder: (context, index) {
return HorizontalPadding();
},
itemCount: items.length));
}
Widget buildMenuControl(BuildContext context) {
const goToPost = 'Open Post';
const copyText = 'Copy Post Text';
const copyUrl = 'Copy URL';
const openExternal = 'Open In Browser';
final options = [
goToPost,
copyText,
openExternal,
copyUrl,
];
return PopupMenuButton<String>(onSelected: (menuOption) async {
if (!context.mounted) {
return;
}
switch (menuOption) {
case goToPost:
await widget.goToPostFunction();
break;
case openExternal:
await openUrlStringInSystembrowser(
context,
widget.status.externalLink,
'Status',
);
break;
case copyUrl:
await copyToClipboard(
context: context,
text: widget.status.externalLink,
message: 'Status link copied to clipboard',
);
break;
case copyText:
await copyToClipboard(
context: context,
text: widget.status.body,
message: 'Status text copied to clipboard',
);
break;
default:
//do nothing
}
}, itemBuilder: (context) {
return options
.map((o) => PopupMenuItem(value: o, child: Text(o)))
.toList();
});
}
}

Wyświetl plik

@ -16,10 +16,12 @@ import '../padding.dart';
class StatusHeaderControl extends StatelessWidget {
static final _logger = Logger('$StatusHeaderControl');
final TimelineEntry entry;
final bool showIsCommentText;
const StatusHeaderControl({
super.key,
required this.entry,
this.showIsCommentText = false,
});
void goToProfile(BuildContext context, String id) {
@ -97,6 +99,11 @@ class StatusHeaderControl extends StatelessWidget {
],
),
],
if (showIsCommentText && entry.parentId.isNotEmpty)
Text(
' ...made a comment:',
style: Theme.of(context).textTheme.bodyText1,
),
],
),
Row(

Wyświetl plik

@ -16,6 +16,7 @@ import 'services/connections_manager.dart';
import 'services/direct_message_service.dart';
import 'services/entry_manager_service.dart';
import 'services/feature_version_checker.dart';
import 'services/fediverse_server_validator.dart';
import 'services/follow_requests_manager.dart';
import 'services/gallery_service.dart';
import 'services/hashtag_service.dart';
@ -36,6 +37,8 @@ Future<void> dependencyInjectionInitialization() async {
getIt.registerSingleton<IHashtagRepo>(ObjectBoxHashtagRepo());
getIt.registerSingleton<HashtagService>(HashtagService());
getIt.registerSingleton<NetworkStatusService>(NetworkStatusService());
getIt.registerSingleton<FediverseServiceValidator>(
FediverseServiceValidator());
getIt.registerSingleton<FriendicaVersionChecker>(
const FriendicaVersionChecker());

Wyświetl plik

@ -19,6 +19,8 @@ import '../models/group_data.dart';
import '../models/image_entry.dart';
import '../models/instance_info.dart';
import '../models/media_attachment_uploads/image_types_enum.dart';
import '../models/search_results.dart';
import '../models/search_types.dart';
import '../models/timeline_entry.dart';
import '../models/user_notification.dart';
import '../models/visibility.dart';
@ -31,6 +33,7 @@ import '../serializers/mastodon/follow_request_mastodon_extensions.dart';
import '../serializers/mastodon/group_data_mastodon_extensions.dart';
import '../serializers/mastodon/instance_info_mastodon_extensions.dart';
import '../serializers/mastodon/notification_mastodon_extension.dart';
import '../serializers/mastodon/search_result_mastodon_extensions.dart';
import '../serializers/mastodon/timeline_entry_mastodon_extensions.dart';
import '../serializers/mastodon/visibility_mastodon_extensions.dart';
import '../services/network_status_service.dart';
@ -778,6 +781,29 @@ class StatusesClient extends FriendicaClient {
}
}
class SearchClient extends FriendicaClient {
static final _logger = Logger('$StatusesClient');
SearchClient(super.credentials) : super();
FutureResult<PagedResponse<SearchResults>, ExecError> search(
SearchTypes type, String searchTerm, PagingData page) async {
_logger.finest(() => 'Searching $type for term: $searchTerm');
_networkStatusService.startSearchLoading();
final url =
'https://$serverName/api/v1/search?${page.toQueryParameters()}&${type.toQueryParameters()}&q=$searchTerm';
final result = await _getApiPagedRequest(
Uri.parse(url),
);
_networkStatusService.finishSearchLoaing();
return result
.andThenSuccess((response) => response
.map((json) => SearchResultMastodonExtensions.fromJson(json)))
.execErrorCast();
}
}
class TimelineClient extends FriendicaClient {
static final _logger = Logger('$TimelineClient');
@ -876,6 +902,14 @@ abstract class FriendicaClient {
.mapError((error) => error as ExecError);
}
FutureResult<PagedResponse<dynamic>, ExecError> _getApiPagedRequest(
Uri url) async {
return (await getUrl(url, headers: _headers).andThenSuccessAsync(
(response) async => response.map((data) => jsonDecode(data)),
))
.mapError((error) => error as ExecError);
}
FutureResult<dynamic, ExecError> _getApiRequest(Uri url) async {
return (await getUrl(url, headers: _headers).andThenSuccessAsync(
(response) async => jsonDecode(response.data),

Wyświetl plik

@ -0,0 +1,35 @@
import 'connection.dart';
import 'timeline_entry.dart';
class SearchResults {
final List<Connection> accounts;
final List<TimelineEntry> statuses;
final List<String> hashtags;
const SearchResults({
required this.accounts,
required this.statuses,
required this.hashtags,
});
factory SearchResults.empty() => const SearchResults(
accounts: [],
statuses: [],
hashtags: [],
);
SearchResults merge(SearchResults newResults) => SearchResults(
accounts: [...accounts, ...newResults.accounts],
statuses: [...statuses, ...newResults.statuses],
hashtags: [...hashtags, ...newResults.hashtags],
);
bool get isEmpty => accounts.isEmpty && statuses.isEmpty && hashtags.isEmpty;
@override
String toString() {
return 'SearchResults{#accounts: ${accounts.length}, #statuses: ${statuses.length}, #hashtags: ${hashtags.length}}';
}
}

Wyświetl plik

@ -0,0 +1,33 @@
enum SearchTypes {
account,
statusesText,
directLink,
hashTag,
;
String toLabel() {
switch (this) {
case SearchTypes.hashTag:
return 'Hashtag';
case SearchTypes.account:
return 'Account';
case SearchTypes.statusesText:
return 'Statuses Text';
case SearchTypes.directLink:
return 'Direct Link';
}
}
String toQueryParameters() {
switch (this) {
case SearchTypes.hashTag:
return 'type=hashtags';
case SearchTypes.account:
return 'type=accounts';
case SearchTypes.statusesText:
return 'type=statuses';
case SearchTypes.directLink:
return 'resolve=true';
}
}
}

Wyświetl plik

@ -14,6 +14,7 @@ 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';
@ -38,6 +39,7 @@ class ScreenPaths {
static String userPosts = '/user_posts';
static String likes = '/likes';
static String reshares = '/reshares';
static String search = '/search';
}
bool needAuthChangeInitialized = true;
@ -236,4 +238,12 @@ final appRouter = GoRouter(
builder: (context, state) =>
UserProfileScreen(userId: state.params['id']!),
),
GoRoute(
path: ScreenPaths.search,
name: ScreenPaths.search,
pageBuilder: (context, state) => NoTransitionPage(
name: ScreenPaths.search,
child: SearchScreen(),
),
),
]);

Wyświetl plik

@ -0,0 +1,334 @@
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/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');
setState(() {
searching = true;
});
if (reset) {
nextPage = PagingData(limit: limit);
}
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;
if (searching) {
body = Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Searching for ${searchType.toLabel()} on: $searchText',
),
],
),
);
} else {
body = buildResultBody(profile);
}
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;
},
onEditingComplete: () {
setState(() {});
},
onSubmitted: (value) {
searchText = value;
updateSearchResults(profile);
},
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),
),
),
),
),
),
ElevatedButton(
onPressed: () {
updateSearchResults(profile);
},
child: const Text('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),
],
),
),
),
bottomNavigationBar: AppBottomNavBar(
currentButton: NavBarButtons.contacts,
),
);
}
Widget buildResultBody(Profile profile) {
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
.andThenSuccessAsync((cm) async => cm.fullRefresh(connection));
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'));
}
});
}
}

Wyświetl plik

@ -0,0 +1,25 @@
import '../../models/search_results.dart';
import 'connection_mastodon_extensions.dart';
import 'timeline_entry_mastodon_extensions.dart';
extension SearchResultMastodonExtensions on SearchResults {
static SearchResults fromJson(Map<String, dynamic> json) {
final accounts = (json['accounts'] as List<dynamic>? ?? [])
.map((j) => ConnectionMastodonExtensions.fromJson(j))
.toList();
final statuses = (json['statuses'] as List<dynamic>? ?? [])
.map((j) => TimelineEntryMastodonExtensions.fromJson(j))
.toList();
final hashtags = (json['hashtags'] as List<dynamic>? ?? [])
.map((j) => j.toString())
.toList();
return SearchResults(
accounts: accounts,
statuses: statuses,
hashtags: hashtags,
);
}
}

Wyświetl plik

@ -7,6 +7,7 @@ class NetworkStatusService {
final interactionsLoadingStatus = ValueNotifier<bool>(false);
final timelineLoadingStatus = ValueNotifier<bool>(false);
final imageGalleryLoadingStatus = ValueNotifier<bool>(false);
final searchLoadingStatus = ValueNotifier<bool>(false);
void startConnectionUpdateStatus() {
connectionUpdateStatus.value = true;
@ -55,4 +56,12 @@ class NetworkStatusService {
void finishInteractionsLoading() {
interactionsLoadingStatus.value = false;
}
void startSearchLoading() {
searchLoadingStatus.value = true;
}
void finishSearchLoaing() {
searchLoadingStatus.value = false;
}
}