diff --git a/packages/neon/neon/lib/l10n/en.arb b/packages/neon/neon/lib/l10n/en.arb index 72d2862f..17af0ba0 100644 --- a/packages/neon/neon/lib/l10n/en.arb +++ b/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", diff --git a/packages/neon/neon/lib/l10n/localizations.dart b/packages/neon/neon/lib/l10n/localizations.dart index 79b78e87..1152702d 100644 --- a/packages/neon/neon/lib/l10n/localizations.dart +++ b/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: diff --git a/packages/neon/neon/lib/l10n/localizations_en.dart b/packages/neon/neon/lib/l10n/localizations_en.dart index 32f6f603..bd75edc4 100644 --- a/packages/neon/neon/lib/l10n/localizations_en.dart +++ b/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'; diff --git a/packages/neon/neon/lib/src/blocs/accounts.dart b/packages/neon/neon/lib/src/blocs/accounts.dart index 88ebd811..f760beeb 100644 --- a/packages/neon/neon/lib/src/blocs/accounts.dart +++ b/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 = {}; final _userDetailsBlocs = {}; final _userStatusesBlocs = {}; + final _unifiedSearchBlocs = {}; @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]. diff --git a/packages/neon/neon/lib/src/blocs/unified_search.dart b/packages/neon/neon/lib/src/blocs/unified_search.dart new file mode 100644 index 00000000..2a998601 --- /dev/null +++ b/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 get enabled; + + BehaviorSubject>?>> 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 enabled = BehaviorSubject.seeded(false); + + @override + BehaviorSubject>?>> 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>> _getLoadingProviders( + final Iterable 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 result) => + results.add( + Result.success( + Map.fromEntries( + _sortResults({ + ...?results.value.data, + provider: result, + }), + ), + ), + ); + + Iterable>> _sortResults( + final Map> 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 result) => + !result.hasData || result.requireData.entries.isNotEmpty; + + int _sortEntriesCount(final Result a, final Result b) => + (b.data?.entries.length ?? 0).compareTo(a.data?.entries.length ?? 0); +} diff --git a/packages/neon/neon/lib/src/pages/home.dart b/packages/neon/neon/lib/src/pages/home.dart index 6db33cc4..5f9fad0e 100644 --- a/packages/neon/neon/lib/src/pages/home.dart +++ b/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,30 +124,38 @@ class _HomePageState extends State { const drawer = NeonDrawer(); const appBar = NeonAppBar(); - final appView = ResultBuilder>.behaviorSubject( - stream: _appsBloc.appImplementations, - builder: (final context, final appImplementations) { - if (!appImplementations.hasData) { - return const SizedBox(); + final appView = StreamBuilder( + stream: _accountsBloc.activeUnifiedSearchBloc.enabled, + builder: (final context, final unifiedSearchEnabledSnapshot) { + if (unifiedSearchEnabledSnapshot.data ?? false) { + return const NeonUnifiedSearchResults(); } - - if (appImplementations.requireData.isEmpty) { - return Center( - child: Text( - AppLocalizations.of(context).errorNoCompatibleNextcloudAppsFound, - textAlign: TextAlign.center, - ), - ); - } - - return StreamBuilder( - stream: _appsBloc.activeApp, - builder: (final context, final activeAppIDSnapshot) { - if (!activeAppIDSnapshot.hasData) { + return ResultBuilder>.behaviorSubject( + stream: _appsBloc.appImplementations, + builder: (final context, final appImplementations) { + if (!appImplementations.hasData) { return const SizedBox(); } - return activeAppIDSnapshot.requireData.page; + if (appImplementations.requireData.isEmpty) { + return Center( + child: Text( + AppLocalizations.of(context).errorNoCompatibleNextcloudAppsFound, + textAlign: TextAlign.center, + ), + ); + } + + return StreamBuilder( + stream: _appsBloc.activeApp, + builder: (final context, final activeAppIDSnapshot) { + if (!activeAppIDSnapshot.hasData) { + return const SizedBox(); + } + + return activeAppIDSnapshot.requireData.page; + }, + ); }, ); }, diff --git a/packages/neon/neon/lib/src/widgets/app_bar.dart b/packages/neon/neon/lib/src/widgets/app_bar.dart index f008a1c6..df6eef6e 100644 --- a/packages/neon/neon/lib/src/widgets/app_bar.dart +++ b/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/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(context, listen: false); - final accounts = accountsBloc.accounts.value; - final account = accountsBloc.activeAccount.value!; - final appsBloc = accountsBloc.activeAppsBloc; - - return ResultBuilder>.behaviorSubject( - stream: appsBloc.appImplementations, - builder: (final context, final appImplementations) => StreamBuilder( - stream: appsBloc.activeApp, - builder: (final context, final activeAppSnapshot) => AppBar( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (activeAppSnapshot.hasData) ...[ - Flexible( - child: Text( - activeAppSnapshot.requireData.name(context), + State createState() => _NeonAppBarState(); +} + +class _NeonAppBarState extends State { + late final AccountsBloc accountsBloc = Provider.of(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(); + late final StreamSubscription _searchTermSubscription; + + @override + void initState() { + super.initState(); + + unifiedSearchBloc.enabled.listen((final enabled) { + 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>.behaviorSubject( + stream: appsBloc.appImplementations, + builder: (final context, final appImplementations) => StreamBuilder( + stream: appsBloc.activeApp, + builder: (final context, final activeAppSnapshot) => StreamBuilder( + 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, + ), + ], + ], ), - ), - ], - 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, + 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(), ], - ), - 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(context, listen: false).activeUnifiedSearchBloc.enable(); + }, + tooltip: AppLocalizations.of(context).search, + icon: const Icon( + Icons.search, + size: kAvatarSize * 2 / 3, + ), + ); } @internal diff --git a/packages/neon/neon/lib/src/widgets/list_view.dart b/packages/neon/neon/lib/src/widgets/list_view.dart index 62025757..110c3ccb 100644 --- a/packages/neon/neon/lib/src/widgets/list_view.dart +++ b/packages/neon/neon/lib/src/widgets/list_view.dart @@ -16,7 +16,7 @@ class NeonListView extends StatelessWidget { super.key, }); - final List? items; + final Iterable? items; final bool isLoading; final dynamic error; final Future Function() onRefresh; diff --git a/packages/neon/neon/lib/src/widgets/server_icon.dart b/packages/neon/neon/lib/src/widgets/server_icon.dart index 5a24f60a..2bc31528 100644 --- a/packages/neon/neon/lib/src/widgets/server_icon.dart +++ b/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', diff --git a/packages/neon/neon/lib/src/widgets/unified_search_results.dart b/packages/neon/neon/lib/src/widgets/unified_search_results.dart new file mode 100644 index 00000000..a496380d --- /dev/null +++ b/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(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 result, + ) { + final entries = result.data?.entries ?? []; + 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; + } +}