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. 49
      packages/neon/neon/lib/src/pages/home.dart
  7. 191
      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!", "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.", "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", "nextPushSupportedInstall": "Install NextPush",
"search": "Search",
"searchCancel": "Cancel search",
"searchNoResults": "No search results",
"settings": "Settings", "settings": "Settings",
"settingsApps": "Apps", "settingsApps": "Apps",
"settingsAccount": "Account", "settingsAccount": "Account",

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

@ -341,6 +341,24 @@ abstract class AppLocalizations {
/// **'Install NextPush'** /// **'Install NextPush'**
String get nextPushSupportedInstall; 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. /// No description provided for @settings.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

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

@ -162,6 +162,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get nextPushSupportedInstall => 'Install NextPush'; String get nextPushSupportedInstall => 'Install NextPush';
@override
String get search => 'Search';
@override
String get searchCancel => 'Cancel search';
@override
String get searchNoResults => 'No search results';
@override @override
String get settings => 'Settings'; 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/bloc/bloc.dart';
import 'package:neon/src/blocs/apps.dart'; import 'package:neon/src/blocs/apps.dart';
import 'package:neon/src/blocs/capabilities.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_details.dart';
import 'package:neon/src/blocs/user_statuses.dart'; import 'package:neon/src/blocs/user_statuses.dart';
import 'package:neon/src/models/account.dart'; import 'package:neon/src/models/account.dart';
@ -112,6 +113,7 @@ class AccountsBloc extends Bloc implements AccountsBlocEvents, AccountsBlocState
final _capabilitiesBlocs = <String, CapabilitiesBloc>{}; final _capabilitiesBlocs = <String, CapabilitiesBloc>{};
final _userDetailsBlocs = <String, UserDetailsBloc>{}; final _userDetailsBlocs = <String, UserDetailsBloc>{};
final _userStatusesBlocs = <String, UserStatusesBloc>{}; final _userStatusesBlocs = <String, UserStatusesBloc>{};
final _unifiedSearchBlocs = <String, UnifiedSearchBloc>{};
@override @override
void dispose() { void dispose() {
@ -121,6 +123,7 @@ class AccountsBloc extends Bloc implements AccountsBlocEvents, AccountsBlocState
_capabilitiesBlocs.disposeAll(); _capabilitiesBlocs.disposeAll();
_userDetailsBlocs.disposeAll(); _userDetailsBlocs.disposeAll();
_userStatusesBlocs.disposeAll(); _userStatusesBlocs.disposeAll();
_unifiedSearchBlocs.disposeAll();
for (final options in _accountsOptions.values) { for (final options in _accountsOptions.values) {
options.dispose(); options.dispose();
} }
@ -298,6 +301,25 @@ class AccountsBloc extends Bloc implements AccountsBlocEvents, AccountsBlocState
account, 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]. /// 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);
}

49
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/app_bar.dart';
import 'package:neon/src/widgets/drawer.dart'; import 'package:neon/src/widgets/drawer.dart';
import 'package:neon/src/widgets/exception.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:neon/src/widgets/user_avatar.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -123,30 +124,38 @@ class _HomePageState extends State<HomePage> {
const drawer = NeonDrawer(); const drawer = NeonDrawer();
const appBar = NeonAppBar(); const appBar = NeonAppBar();
final appView = ResultBuilder<Iterable<AppImplementation>>.behaviorSubject( final appView = StreamBuilder(
stream: _appsBloc.appImplementations, stream: _accountsBloc.activeUnifiedSearchBloc.enabled,
builder: (final context, final appImplementations) { builder: (final context, final unifiedSearchEnabledSnapshot) {
if (!appImplementations.hasData) { if (unifiedSearchEnabledSnapshot.data ?? false) {
return const SizedBox(); return const NeonUnifiedSearchResults();
} }
return ResultBuilder<Iterable<AppImplementation>>.behaviorSubject(
if (appImplementations.requireData.isEmpty) { stream: _appsBloc.appImplementations,
return Center( builder: (final context, final appImplementations) {
child: Text( if (!appImplementations.hasData) {
AppLocalizations.of(context).errorNoCompatibleNextcloudAppsFound,
textAlign: TextAlign.center,
),
);
}
return StreamBuilder<AppImplementation>(
stream: _appsBloc.activeApp,
builder: (final context, final activeAppIDSnapshot) {
if (!activeAppIDSnapshot.hasData) {
return const SizedBox(); return const SizedBox();
} }
return activeAppIDSnapshot.requireData.page; if (appImplementations.requireData.isEmpty) {
return Center(
child: Text(
AppLocalizations.of(context).errorNoCompatibleNextcloudAppsFound,
textAlign: TextAlign.center,
),
);
}
return StreamBuilder<AppImplementation>(
stream: _appsBloc.activeApp,
builder: (final context, final activeAppIDSnapshot) {
if (!activeAppIDSnapshot.hasData) {
return const SizedBox();
}
return activeAppIDSnapshot.requireData.page;
},
);
}, },
); );
}, },

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

@ -15,76 +15,157 @@ import 'package:neon/src/widgets/exception.dart';
import 'package:neon/src/widgets/linear_progress_indicator.dart'; import 'package:neon/src/widgets/linear_progress_indicator.dart';
import 'package:neon/src/widgets/user_avatar.dart'; import 'package:neon/src/widgets/user_avatar.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:rxdart/rxdart.dart';
@internal @internal
class NeonAppBar extends StatelessWidget implements PreferredSizeWidget { class NeonAppBar extends StatefulWidget implements PreferredSizeWidget {
const NeonAppBar({super.key}); const NeonAppBar({super.key});
@override @override
Size get preferredSize => const Size.fromHeight(kToolbarHeight); Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@override @override
Widget build(final BuildContext context) { State<NeonAppBar> createState() => _NeonAppBarState();
final accountsBloc = Provider.of<AccountsBloc>(context, listen: false); }
final accounts = accountsBloc.accounts.value;
final account = accountsBloc.activeAccount.value!; class _NeonAppBarState extends State<NeonAppBar> {
final appsBloc = accountsBloc.activeAppsBloc; late final AccountsBloc accountsBloc = Provider.of<AccountsBloc>(context, listen: false);
late final accounts = accountsBloc.accounts.value;
return ResultBuilder<Iterable<AppImplementation>>.behaviorSubject( late final account = accountsBloc.activeAccount.value!;
stream: appsBloc.appImplementations, late final appsBloc = accountsBloc.activeAppsBloc;
builder: (final context, final appImplementations) => StreamBuilder<AppImplementation>( late final unifiedSearchBloc = accountsBloc.activeUnifiedSearchBloc;
stream: appsBloc.activeApp,
builder: (final context, final activeAppSnapshot) => AppBar( final _searchBarFocusNode = FocusNode();
title: Column( final _searchTermController = StreamController<String>();
crossAxisAlignment: CrossAxisAlignment.start, late final StreamSubscription _searchTermSubscription;
children: [
Row( @override
children: [ void initState() {
if (activeAppSnapshot.hasData) ...[ super.initState();
Flexible(
child: Text( unifiedSearchBloc.enabled.listen((final enabled) {
activeAppSnapshot.requireData.name(context), if (enabled) {
_searchBarFocusNode.requestFocus();
}
});
_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) => 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(
children: [
if (activeAppSnapshot.hasData) ...[
Flexible(
child: Text(
activeAppSnapshot.requireData.name(context),
),
),
],
if (appImplementations.hasError) ...[
const SizedBox(
width: 8,
),
NeonException(
appImplementations.error,
onRetry: appsBloc.refresh,
onlyIcon: true,
),
],
if (appImplementations.isLoading) ...[
const SizedBox(
width: 8,
),
Expanded(
child: NeonLinearProgressIndicator(
color: Theme.of(context).appBarTheme.foregroundColor,
),
),
],
],
),
if (accounts.length > 1) ...[
Text(
account.humanReadableID,
style: Theme.of(context).textTheme.bodySmall,
),
],
],
), ),
), actions: [
], if (unifiedSearchEnabled) ...[
if (appImplementations.hasError) ...[ Flexible(
const SizedBox( child: SearchBar(
width: 8, focusNode: _searchBarFocusNode,
), hintText: AppLocalizations.of(context).search,
NeonException( padding: const MaterialStatePropertyAll(EdgeInsets.only(left: 16)),
appImplementations.error, onChanged: _searchTermController.add,
onRetry: appsBloc.refresh, trailing: [
onlyIcon: true, IconButton(
), onPressed: () {
], unifiedSearchBloc.disable();
if (appImplementations.isLoading) ...[ },
const SizedBox( tooltip: AppLocalizations.of(context).searchCancel,
width: 8, icon: const Icon(
), Icons.close,
Expanded( size: kAvatarSize * 2 / 3,
child: NeonLinearProgressIndicator( ),
color: Theme.of(context).appBarTheme.foregroundColor, ),
],
), ),
), ),
] else ...[
const SearchIconButton(),
], ],
const NotificationIconButton(),
const AccountSwitcherButton(),
], ],
), );
if (accounts.length > 1) ...[ },
Text(
account.humanReadableID,
style: Theme.of(context).textTheme.bodySmall,
),
],
],
), ),
actions: const [
NotificationIconButton(),
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 @internal

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

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

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

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