Browse Source

feat(neon): Implement unified search

Signed-off-by: jld3103 <jld3103yt@gmail.com>
pull/399/head
jld3103 1 year ago
parent
commit
9c6bdef193
No known key found for this signature in database
GPG Key ID: 9062417B9E8EB7B3
  1. 3
      packages/neon/neon/lib/l10n/en.arb
  2. 18
      packages/neon/neon/lib/l10n/localizations.dart
  3. 9
      packages/neon/neon/lib/l10n/localizations_en.dart
  4. 22
      packages/neon/neon/lib/src/blocs/accounts.dart
  5. 160
      packages/neon/neon/lib/src/blocs/unified_search.dart
  6. 11
      packages/neon/neon/lib/src/pages/home.dart
  7. 105
      packages/neon/neon/lib/src/widgets/app_bar.dart
  8. 2
      packages/neon/neon/lib/src/widgets/list_view.dart
  9. 4
      packages/neon/neon/lib/src/widgets/server_icon.dart
  10. 143
      packages/neon/neon/lib/src/widgets/unified_search_results.dart

3
packages/neon/neon/lib/l10n/en.arb

@ -89,6 +89,9 @@
"nextPushSupported": "NextPush is supported!",
"nextPushSupportedText": "NextPush is a FOSS way of receiving push notifications using the UnifiedPush protocol via a Nextcloud instance.\nYou can install NextPush from the F-Droid app store.",
"nextPushSupportedInstall": "Install NextPush",
"search": "Search",
"searchCancel": "Cancel search",
"searchNoResults": "No search results",
"settings": "Settings",
"settingsApps": "Apps",
"settingsAccount": "Account",

18
packages/neon/neon/lib/l10n/localizations.dart

@ -341,6 +341,24 @@ abstract class AppLocalizations {
/// **'Install NextPush'**
String get nextPushSupportedInstall;
/// No description provided for @search.
///
/// In en, this message translates to:
/// **'Search'**
String get search;
/// No description provided for @searchCancel.
///
/// In en, this message translates to:
/// **'Cancel search'**
String get searchCancel;
/// No description provided for @searchNoResults.
///
/// In en, this message translates to:
/// **'No search results'**
String get searchNoResults;
/// No description provided for @settings.
///
/// In en, this message translates to:

9
packages/neon/neon/lib/l10n/localizations_en.dart

@ -162,6 +162,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get nextPushSupportedInstall => 'Install NextPush';
@override
String get search => 'Search';
@override
String get searchCancel => 'Cancel search';
@override
String get searchNoResults => 'No search results';
@override
String get settings => 'Settings';

22
packages/neon/neon/lib/src/blocs/accounts.dart

@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:neon/src/bloc/bloc.dart';
import 'package:neon/src/blocs/apps.dart';
import 'package:neon/src/blocs/capabilities.dart';
import 'package:neon/src/blocs/unified_search.dart';
import 'package:neon/src/blocs/user_details.dart';
import 'package:neon/src/blocs/user_statuses.dart';
import 'package:neon/src/models/account.dart';
@ -112,6 +113,7 @@ class AccountsBloc extends Bloc implements AccountsBlocEvents, AccountsBlocState
final _capabilitiesBlocs = <String, CapabilitiesBloc>{};
final _userDetailsBlocs = <String, UserDetailsBloc>{};
final _userStatusesBlocs = <String, UserStatusesBloc>{};
final _unifiedSearchBlocs = <String, UnifiedSearchBloc>{};
@override
void dispose() {
@ -121,6 +123,7 @@ class AccountsBloc extends Bloc implements AccountsBlocEvents, AccountsBlocState
_capabilitiesBlocs.disposeAll();
_userDetailsBlocs.disposeAll();
_userStatusesBlocs.disposeAll();
_unifiedSearchBlocs.disposeAll();
for (final options in _accountsOptions.values) {
options.dispose();
}
@ -298,6 +301,25 @@ class AccountsBloc extends Bloc implements AccountsBlocEvents, AccountsBlocState
account,
);
}
/// The UnifiedSearchBloc for the [activeAccount].
///
/// Convenience method for [getUnifiedSearchBlocFor] with the currently active account.
UnifiedSearchBloc get activeUnifiedSearchBloc => getUnifiedSearchBlocFor(aa);
/// The UnifiedSearchBloc for the specified [account].
///
/// Use [activeUnifiedSearchBloc] to get them for the [activeAccount].
UnifiedSearchBloc getUnifiedSearchBlocFor(final Account account) {
if (_unifiedSearchBlocs[account.id] != null) {
return _unifiedSearchBlocs[account.id]!;
}
return _unifiedSearchBlocs[account.id] = UnifiedSearchBloc(
getAppsBlocFor(account),
account,
);
}
}
/// Get a list of logged in accounts from [storage].

160
packages/neon/neon/lib/src/blocs/unified_search.dart

@ -0,0 +1,160 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:meta/meta.dart';
import 'package:neon/models.dart';
import 'package:neon/src/bloc/bloc.dart';
import 'package:neon/src/bloc/result.dart';
import 'package:neon/src/blocs/apps.dart';
import 'package:nextcloud/nextcloud.dart';
import 'package:rxdart/rxdart.dart';
abstract interface class UnifiedSearchBlocEvents {
void search(final String term);
void enable();
void disable();
}
abstract interface class UnifiedSearchBlocStates {
BehaviorSubject<bool> get enabled;
BehaviorSubject<Result<Map<CoreUnifiedSearchProvider, Result<CoreUnifiedSearchResult>>?>> get results;
}
@internal
class UnifiedSearchBloc extends InteractiveBloc implements UnifiedSearchBlocEvents, UnifiedSearchBlocStates {
UnifiedSearchBloc(
this._appsBloc,
this._account,
) {
_appsBloc.activeApp.listen((final _) {
if (enabled.value) {
disable();
}
});
}
final AppsBloc _appsBloc;
final Account _account;
String _term = '';
@override
BehaviorSubject<bool> enabled = BehaviorSubject.seeded(false);
@override
BehaviorSubject<Result<Map<CoreUnifiedSearchProvider, Result<CoreUnifiedSearchResult>>?>> results =
BehaviorSubject.seeded(Result.success(null));
@override
void dispose() {
unawaited(enabled.close());
unawaited(results.close());
super.dispose();
}
@override
Future refresh() async {
await _search();
}
@override
Future search(final String term) async {
_term = term.trim();
await _search();
}
@override
void enable() {
enabled.add(true);
}
@override
void disable() {
enabled.add(false);
results.add(Result.success(null));
_term = '';
}
Future _search() async {
if (_term.isEmpty) {
results.add(Result.success(null));
return;
}
try {
results.add(results.value.asLoading());
final providers = (await _account.client.core.unifiedSearch.getProviders()).ocs.data;
results.add(
Result.success(Map.fromEntries(_getLoadingProviders(providers))),
);
for (final provider in providers) {
unawaited(_searchProvider(provider));
}
} catch (e, s) {
debugPrint(e.toString());
debugPrint(s.toString());
results.add(Result.error(e));
}
}
Iterable<MapEntry<CoreUnifiedSearchProvider, Result<CoreUnifiedSearchResult>>> _getLoadingProviders(
final Iterable<CoreUnifiedSearchProvider> providers,
) sync* {
for (final provider in providers) {
yield MapEntry(provider, Result.loading());
}
}
Future _searchProvider(final CoreUnifiedSearchProvider provider) async {
_updateResults(provider, Result.loading());
try {
final response = await _account.client.core.unifiedSearch.search(
providerId: provider.id,
term: _term,
);
_updateResults(provider, Result.success(response.ocs.data));
} catch (e, s) {
debugPrint(e.toString());
debugPrint(s.toString());
_updateResults(provider, Result.error(e));
}
}
void _updateResults(final CoreUnifiedSearchProvider provider, final Result<CoreUnifiedSearchResult> result) =>
results.add(
Result.success(
Map.fromEntries(
_sortResults({
...?results.value.data,
provider: result,
}),
),
),
);
Iterable<MapEntry<CoreUnifiedSearchProvider, Result<CoreUnifiedSearchResult>>> _sortResults(
final Map<CoreUnifiedSearchProvider, Result<CoreUnifiedSearchResult>> results,
) sync* {
final activeApp = _appsBloc.activeApp.value;
yield* results.entries
.where((final entry) => _providerMatchesApp(entry.key, activeApp))
.sorted((final a, final b) => _sortEntriesCount(a.value, b.value));
yield* results.entries
.whereNot((final entry) => _providerMatchesApp(entry.key, activeApp))
.where((final entry) => _hasEntries(entry.value))
.sorted((final a, final b) => _sortEntriesCount(a.value, b.value));
}
bool _providerMatchesApp(final CoreUnifiedSearchProvider provider, final AppImplementation app) =>
provider.id == app.id || provider.id.startsWith('${app.id}_');
bool _hasEntries(final Result<CoreUnifiedSearchResult> result) =>
!result.hasData || result.requireData.entries.isNotEmpty;
int _sortEntriesCount(final Result<CoreUnifiedSearchResult> a, final Result<CoreUnifiedSearchResult> b) =>
(b.data?.entries.length ?? 0).compareTo(a.data?.entries.length ?? 0);
}

11
packages/neon/neon/lib/src/pages/home.dart

@ -13,6 +13,7 @@ import 'package:neon/src/utils/global_popups.dart';
import 'package:neon/src/widgets/app_bar.dart';
import 'package:neon/src/widgets/drawer.dart';
import 'package:neon/src/widgets/exception.dart';
import 'package:neon/src/widgets/unified_search_results.dart';
import 'package:neon/src/widgets/user_avatar.dart';
import 'package:provider/provider.dart';
@ -123,7 +124,13 @@ class _HomePageState extends State<HomePage> {
const drawer = NeonDrawer();
const appBar = NeonAppBar();
final appView = ResultBuilder<Iterable<AppImplementation>>.behaviorSubject(
final appView = StreamBuilder(
stream: _accountsBloc.activeUnifiedSearchBloc.enabled,
builder: (final context, final unifiedSearchEnabledSnapshot) {
if (unifiedSearchEnabledSnapshot.data ?? false) {
return const NeonUnifiedSearchResults();
}
return ResultBuilder<Iterable<AppImplementation>>.behaviorSubject(
stream: _appsBloc.appImplementations,
builder: (final context, final appImplementations) {
if (!appImplementations.hasData) {
@ -151,6 +158,8 @@ class _HomePageState extends State<HomePage> {
);
},
);
},
);
final body = ValueListenableBuilder<global_options.NavigationMode>(
valueListenable: _globalOptions.navigationMode,

105
packages/neon/neon/lib/src/widgets/app_bar.dart

@ -15,27 +15,64 @@ import 'package:neon/src/widgets/exception.dart';
import 'package:neon/src/widgets/linear_progress_indicator.dart';
import 'package:neon/src/widgets/user_avatar.dart';
import 'package:provider/provider.dart';
import 'package:rxdart/rxdart.dart';
@internal
class NeonAppBar extends StatelessWidget implements PreferredSizeWidget {
class NeonAppBar extends StatefulWidget implements PreferredSizeWidget {
const NeonAppBar({super.key});
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@override
Widget build(final BuildContext context) {
final accountsBloc = Provider.of<AccountsBloc>(context, listen: false);
final accounts = accountsBloc.accounts.value;
final account = accountsBloc.activeAccount.value!;
final appsBloc = accountsBloc.activeAppsBloc;
State<NeonAppBar> createState() => _NeonAppBarState();
}
class _NeonAppBarState extends State<NeonAppBar> {
late final AccountsBloc accountsBloc = Provider.of<AccountsBloc>(context, listen: false);
late final accounts = accountsBloc.accounts.value;
late final account = accountsBloc.activeAccount.value!;
late final appsBloc = accountsBloc.activeAppsBloc;
late final unifiedSearchBloc = accountsBloc.activeUnifiedSearchBloc;
final _searchBarFocusNode = FocusNode();
final _searchTermController = StreamController<String>();
late final StreamSubscription _searchTermSubscription;
@override
void initState() {
super.initState();
unifiedSearchBloc.enabled.listen((final enabled) {
if (enabled) {
_searchBarFocusNode.requestFocus();
}
});
return ResultBuilder<Iterable<AppImplementation>>.behaviorSubject(
_searchTermSubscription =
_searchTermController.stream.debounceTime(const Duration(milliseconds: 250)).listen(unifiedSearchBloc.search);
}
@override
void dispose() {
unawaited(_searchTermSubscription.cancel());
unawaited(_searchTermController.close());
super.dispose();
}
@override
Widget build(final BuildContext context) => ResultBuilder<Iterable<AppImplementation>>.behaviorSubject(
stream: appsBloc.appImplementations,
builder: (final context, final appImplementations) => StreamBuilder<AppImplementation>(
stream: appsBloc.activeApp,
builder: (final context, final activeAppSnapshot) => AppBar(
title: Column(
builder: (final context, final activeAppSnapshot) => StreamBuilder<bool>(
stream: unifiedSearchBloc.enabled,
builder: (final context, final unifiedSearchEnabledSnapshot) {
final unifiedSearchEnabled = unifiedSearchEnabledSnapshot.data ?? false;
return AppBar(
title: unifiedSearchEnabled
? null
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
@ -77,14 +114,58 @@ class NeonAppBar extends StatelessWidget implements PreferredSizeWidget {
],
],
),
actions: const [
NotificationIconButton(),
AccountSwitcherButton(),
actions: [
if (unifiedSearchEnabled) ...[
Flexible(
child: SearchBar(
focusNode: _searchBarFocusNode,
hintText: AppLocalizations.of(context).search,
padding: const MaterialStatePropertyAll(EdgeInsets.only(left: 16)),
onChanged: _searchTermController.add,
trailing: [
IconButton(
onPressed: () {
unifiedSearchBloc.disable();
},
tooltip: AppLocalizations.of(context).searchCancel,
icon: const Icon(
Icons.close,
size: kAvatarSize * 2 / 3,
),
),
],
),
),
] else ...[
const SearchIconButton(),
],
const NotificationIconButton(),
const AccountSwitcherButton(),
],
);
},
),
),
);
}
@internal
class SearchIconButton extends StatelessWidget {
const SearchIconButton({
super.key,
});
@override
Widget build(final BuildContext context) => IconButton(
onPressed: () {
Provider.of<AccountsBloc>(context, listen: false).activeUnifiedSearchBloc.enable();
},
tooltip: AppLocalizations.of(context).search,
icon: const Icon(
Icons.search,
size: kAvatarSize * 2 / 3,
),
);
}
@internal

2
packages/neon/neon/lib/src/widgets/list_view.dart

@ -16,7 +16,7 @@ class NeonListView<T> extends StatelessWidget {
super.key,
});
final List<T>? items;
final Iterable<T>? items;
final bool isLoading;
final dynamic error;
final Future Function() onRefresh;

4
packages/neon/neon/lib/src/widgets/server_icon.dart

@ -5,14 +5,18 @@ class NeonServerIcon extends StatelessWidget {
const NeonServerIcon({
required this.icon,
this.color,
this.size,
super.key,
});
final String icon;
final Color? color;
final Size? size;
@override
Widget build(final BuildContext context) => VectorGraphic(
width: size?.width,
height: size?.height,
colorFilter: color != null ? ColorFilter.mode(color!, BlendMode.srcIn) : null,
loader: AssetBytesLoader(
'assets/icons/server/${icon.replaceFirst(RegExp('^icon-'), '').replaceFirst(RegExp(r'-(dark|white)$'), '')}.svg.vec',

143
packages/neon/neon/lib/src/widgets/unified_search_results.dart

@ -0,0 +1,143 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:meta/meta.dart';
import 'package:neon/l10n/localizations.dart';
import 'package:neon/src/bloc/result.dart';
import 'package:neon/src/bloc/result_builder.dart';
import 'package:neon/src/blocs/accounts.dart';
import 'package:neon/src/blocs/unified_search.dart';
import 'package:neon/src/models/account.dart';
import 'package:neon/src/widgets/cached_image.dart';
import 'package:neon/src/widgets/exception.dart';
import 'package:neon/src/widgets/image_wrapper.dart';
import 'package:neon/src/widgets/linear_progress_indicator.dart';
import 'package:neon/src/widgets/list_view.dart';
import 'package:neon/src/widgets/server_icon.dart';
import 'package:neon/src/widgets/user_avatar.dart';
import 'package:nextcloud/nextcloud.dart';
import 'package:provider/provider.dart';
@internal
class NeonUnifiedSearchResults extends StatelessWidget {
const NeonUnifiedSearchResults({
super.key,
});
@override
Widget build(final BuildContext context) {
final accountsBloc = Provider.of<AccountsBloc>(context, listen: false);
final bloc = accountsBloc.activeUnifiedSearchBloc;
return ResultBuilder.behaviorSubject(
stream: bloc.results,
builder: (final context, final results) => NeonListView(
items: results.data?.entries,
isLoading: results.isLoading,
error: results.error,
onRefresh: bloc.refresh,
builder: (final context, final snapshot) => AnimatedSize(
duration: const Duration(milliseconds: 100),
child: _buildProvider(
context,
accountsBloc,
bloc,
snapshot.key,
snapshot.value,
),
),
),
);
}
Widget _buildProvider(
final BuildContext context,
final AccountsBloc accountsBloc,
final UnifiedSearchBloc bloc,
final CoreUnifiedSearchProvider provider,
final Result<CoreUnifiedSearchResult> result,
) {
final entries = result.data?.entries ?? <CoreUnifiedSearchResultEntry>[];
return Card(
child: Container(
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
provider.name,
style: Theme.of(context).textTheme.headlineSmall,
),
NeonException(
result.error,
onRetry: bloc.refresh,
),
NeonLinearProgressIndicator(
visible: result.isLoading,
),
if (entries.isEmpty) ...[
ListTile(
leading: const Icon(
Icons.close,
size: kAvatarSize,
),
title: Text(AppLocalizations.of(context).searchNoResults),
),
],
for (final entry in entries) ...[
ListTile(
leading: NeonImageWrapper(
size: const Size.square(kAvatarSize),
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: _buildThumbnail(context, accountsBloc.activeAccount.value!, entry),
),
title: Text(entry.title),
subtitle: Text(entry.subline),
onTap: () async {
context.go(entry.resourceUrl);
},
),
],
],
),
),
);
}
Widget _buildThumbnail(final BuildContext context, final Account account, final CoreUnifiedSearchResultEntry entry) {
if (entry.thumbnailUrl.isNotEmpty) {
return NeonCachedImage.url(
size: const Size.square(kAvatarSize),
url: entry.thumbnailUrl,
account: account,
// The thumbnail URL might be set but a 404 is returned because there is no preview available
errorBuilder: (final context, final _) => _buildFallbackIcon(context, account, entry),
);
}
return _buildFallbackIcon(context, account, entry) ?? const SizedBox.shrink();
}
Widget? _buildFallbackIcon(
final BuildContext context,
final Account account,
final CoreUnifiedSearchResultEntry entry,
) {
const size = Size.square(kAvatarSize * 2 / 3);
if (entry.icon.startsWith('/')) {
return NeonCachedImage.url(
size: size,
url: entry.icon,
account: account,
);
}
if (entry.icon.startsWith('icon-')) {
return NeonServerIcon(
size: size,
color: Theme.of(context).colorScheme.primary,
icon: entry.icon,
);
}
return null;
}
}
Loading…
Cancel
Save