From 2398bdba82ecdabd96e92c1b643ebe6b052f7512 Mon Sep 17 00:00:00 2001 From: jld3103 Date: Fri, 18 Aug 2023 18:43:03 +0200 Subject: [PATCH 1/5] feat(neon): Add URI completion and stripping Signed-off-by: jld3103 --- .../neon/neon/lib/src/models/account.dart | 22 ++++++++ packages/neon/neon/test/account_test.dart | 56 ++++++++++++++++--- 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/packages/neon/neon/lib/src/models/account.dart b/packages/neon/neon/lib/src/models/account.dart index 4c91a3ff..a690483a 100644 --- a/packages/neon/neon/lib/src/models/account.dart +++ b/packages/neon/neon/lib/src/models/account.dart @@ -77,6 +77,28 @@ class Account implements Credentials { // Maybe also show path if it is not '/' ? return '$username@${uri.port != 443 ? '${uri.host}:${uri.port}' : uri.host}'; } + + /// Completes an incomplete [Uri] using the [serverURL]. + /// + /// Some Nextcloud APIs return [Uri]s to resources on the server (e.g. an image) but only give an absolute path. + /// Those [Uri]s need to be completed using the [serverURL] to have a working [Uri]. + /// + /// The paths of the [serverURL] and the [uri] need to be join to get the full path, unless the [uri] path is already an absolute path. + /// In that case an instance hosted at a sub folder will already contain the sub folder part in the [uri]. + Uri completeUri(final Uri uri) { + final result = Uri.parse(serverURL).replace( + queryParameters: uri.queryParameters, + fragment: uri.fragment, + ); + return result.replace( + path: uri.hasAbsolutePath ? uri.path : '${result.path}/${uri.path}', + ); + } + + /// Removes the [serverURL] part from the [uri]. + /// + /// Should be used when trying to push a [uri] from an API to the router as it might contain the scheme, host and sub path of the instance which will not work with the router. + Uri stripUri(final Uri uri) => Uri.parse(uri.toString().replaceFirst(serverURL, '')); } Map _idCache = {}; diff --git a/packages/neon/neon/test/account_test.dart b/packages/neon/neon/test/account_test.dart index 57b7c527..a86b934e 100644 --- a/packages/neon/neon/test/account_test.dart +++ b/packages/neon/neon/test/account_test.dart @@ -2,16 +2,16 @@ import 'package:neon/src/models/account.dart'; import 'package:test/test.dart'; void main() { - const qrCodePath = '/user:JohnDoe&password:super_secret&server:example.com'; - const qrCode = 'nc://login$qrCodePath'; - const invalidUrl = '::Not valid LoginQrcode::'; - const credentials = LoginQrcode( - serverURL: 'example.com', - username: 'JohnDoe', - password: 'super_secret', - ); - group('LoginQrcode', () { + const qrCodePath = '/user:JohnDoe&password:super_secret&server:example.com'; + const qrCode = 'nc://login$qrCodePath'; + const invalidUrl = '::Not valid LoginQrcode::'; + const credentials = LoginQrcode( + serverURL: 'example.com', + username: 'JohnDoe', + password: 'super_secret', + ); + test('parse', () { expect(LoginQrcode.tryParse(qrCode), equals(credentials)); expect(LoginQrcode.tryParse(qrCodePath), equals(credentials)); @@ -22,4 +22,42 @@ void main() { expect(credentials, equals(credentials)); }); }); + + group('URI', () { + const testURL = 'apps/test?123=456#789'; + + for (final (serverURL, path) in [ + ('http://localhost', ''), + ('http://localhost:443', ''), + ('http://localhost:443/nextcloud', '/nextcloud'), + ]) { + group(serverURL, () { + final account = Account( + serverURL: serverURL, + username: 'example', + ); + + test('Complete absolute path', () { + expect( + account.completeUri(Uri.parse('$path/$testURL')), + Uri.parse('$serverURL/$testURL'), + ); + }); + + test('Complete relative path', () { + expect( + account.completeUri(Uri.parse(testURL)), + Uri.parse('$serverURL/$testURL'), + ); + }); + + test('Strip', () { + expect( + account.stripUri(Uri.parse('$serverURL/$testURL')), + Uri.parse('/$testURL'), + ); + }); + }); + } + }); } From d3332a7c0ed7d1ad1e51f7a7de33bfaa1509c525 Mon Sep 17 00:00:00 2001 From: jld3103 Date: Fri, 18 Aug 2023 20:01:33 +0200 Subject: [PATCH 2/5] feat(neon): Add error builder for cached images Signed-off-by: jld3103 --- .../neon/lib/src/widgets/cached_image.dart | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/neon/neon/lib/src/widgets/cached_image.dart b/packages/neon/neon/lib/src/widgets/cached_image.dart index 0d6f6496..4b02d603 100644 --- a/packages/neon/neon/lib/src/widgets/cached_image.dart +++ b/packages/neon/neon/lib/src/widgets/cached_image.dart @@ -11,6 +11,7 @@ import 'package:neon/src/widgets/linear_progress_indicator.dart'; typedef CacheReviver = FutureOr Function(CacheManager cacheManager); typedef ImageDownloader = FutureOr Function(); typedef CacheWriter = Future Function(CacheManager cacheManager, Uint8List image); +typedef ErrorWidgetBuilder = Widget? Function(BuildContext, dynamic); class NeonCachedImage extends StatefulWidget { const NeonCachedImage({ @@ -21,6 +22,7 @@ class NeonCachedImage extends StatefulWidget { this.fit, this.svgColor, this.iconColor, + this.errorBuilder, }); NeonCachedImage.url({ @@ -31,6 +33,7 @@ class NeonCachedImage extends StatefulWidget { this.fit, this.svgColor, this.iconColor, + this.errorBuilder, }) : image = _getImageFromUrl(url), super(key: key ?? Key(url)); @@ -44,6 +47,7 @@ class NeonCachedImage extends StatefulWidget { this.fit, this.svgColor, this.iconColor, + this.errorBuilder, }) : image = _customImageGetter( reviver, getImage, @@ -61,6 +65,8 @@ class NeonCachedImage extends StatefulWidget { final Color? svgColor; final Color? iconColor; + final ErrorWidgetBuilder? errorBuilder; + static Future _getImageFromUrl(final String url) async { final file = await _cacheManager.getSingleFile(url); return file.readAsBytes(); @@ -116,6 +122,10 @@ class _NeonCachedImageState extends State { child: FutureBuilder( future: widget.image, builder: (final context, final fileSnapshot) { + if (fileSnapshot.hasError) { + return _buildError(fileSnapshot.error); + } + if (!fileSnapshot.hasData) { return SizedBox( width: widget.size?.width, @@ -125,18 +135,6 @@ class _NeonCachedImageState extends State { ); } - if (fileSnapshot.hasError) { - return NeonException( - fileSnapshot.error, - onRetry: () { - setState(() {}); - }, - onlyIcon: true, - iconSize: widget.size?.shortestSide, - color: widget.iconColor ?? Theme.of(context).colorScheme.error, - ); - } - final content = fileSnapshot.requireData; try { @@ -160,8 +158,20 @@ class _NeonCachedImageState extends State { width: widget.size?.width, fit: widget.fit, gaplessPlayback: true, + errorBuilder: (final context, final error, final stacktrace) => _buildError(error), ); }, ), ); + + Widget _buildError(final dynamic error) => + widget.errorBuilder?.call(context, error) ?? + NeonException( + error, + onRetry: () { + setState(() {}); + }, + onlyIcon: true, + iconSize: widget.size?.shortestSide, + ); } From 8a6c5e4c5d5146b38de6c44a519bd0892e1f6e1f Mon Sep 17 00:00:00 2001 From: jld3103 Date: Fri, 18 Aug 2023 20:04:34 +0200 Subject: [PATCH 3/5] feat(neon): Complete URL and add headers for cached images Signed-off-by: jld3103 --- .../neon/lib/src/widgets/cached_image.dart | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/neon/neon/lib/src/widgets/cached_image.dart b/packages/neon/neon/lib/src/widgets/cached_image.dart index 4b02d603..dd865cea 100644 --- a/packages/neon/neon/lib/src/widgets/cached_image.dart +++ b/packages/neon/neon/lib/src/widgets/cached_image.dart @@ -5,6 +5,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:neon/src/models/account.dart'; import 'package:neon/src/widgets/exception.dart'; import 'package:neon/src/widgets/linear_progress_indicator.dart'; @@ -27,6 +28,7 @@ class NeonCachedImage extends StatefulWidget { NeonCachedImage.url({ required final String url, + final Account? account, final Key? key, this.isSvgHint = false, this.size, @@ -34,7 +36,7 @@ class NeonCachedImage extends StatefulWidget { this.svgColor, this.iconColor, this.errorBuilder, - }) : image = _getImageFromUrl(url), + }) : image = _getImageFromUrl(url, account), super(key: key ?? Key(url)); NeonCachedImage.custom({ @@ -67,8 +69,18 @@ class NeonCachedImage extends StatefulWidget { final ErrorWidgetBuilder? errorBuilder; - static Future _getImageFromUrl(final String url) async { - final file = await _cacheManager.getSingleFile(url); + static Future _getImageFromUrl(final String url, final Account? account) async { + var uri = Uri.parse(url); + if (account != null) { + uri = account.completeUri(uri); + } + final file = await _cacheManager.getSingleFile( + uri.toString(), + headers: + account != null && uri.host == Uri.parse(account.serverURL).host && account.client.authentications.isNotEmpty + ? account.client.authentications.first.headers + : null, + ); return file.readAsBytes(); } From 64f237b079b9d66da73ce5a2c7cb93b570b37f57 Mon Sep 17 00:00:00 2001 From: jld3103 Date: Fri, 18 Aug 2023 18:45:31 +0200 Subject: [PATCH 4/5] feat(neon): Open unimplemented routes in browser Signed-off-by: jld3103 --- packages/neon/neon/lib/l10n/en.arb | 8 +++ .../neon/neon/lib/l10n/localizations.dart | 6 ++ .../neon/neon/lib/l10n/localizations_en.dart | 5 ++ .../neon/lib/src/pages/route_not_found.dart | 64 +++++++++++++++++++ packages/neon/neon/lib/src/router.dart | 17 ++++- 5 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 packages/neon/neon/lib/src/pages/route_not_found.dart diff --git a/packages/neon/neon/lib/l10n/en.arb b/packages/neon/neon/lib/l10n/en.arb index a4dec86c..72d2862f 100644 --- a/packages/neon/neon/lib/l10n/en.arb +++ b/packages/neon/neon/lib/l10n/en.arb @@ -70,6 +70,14 @@ "errorEmptyField": "This field can not be empty", "errorInvalidURL": "Invalid URL provided", "errorInvalidQrcode": "Invalid QR-Code provided", + "errorRouteNotFound": "Route not found: {route}", + "@errorRouteNotFound" : { + "placeholders": { + "route": { + "type": "String" + } + } + }, "actionYes": "Yes", "actionNo": "No", "actionClose": "Close", diff --git a/packages/neon/neon/lib/l10n/localizations.dart b/packages/neon/neon/lib/l10n/localizations.dart index 609d5a97..79b78e87 100644 --- a/packages/neon/neon/lib/l10n/localizations.dart +++ b/packages/neon/neon/lib/l10n/localizations.dart @@ -269,6 +269,12 @@ abstract class AppLocalizations { /// **'Invalid QR-Code provided'** String get errorInvalidQrcode; + /// No description provided for @errorRouteNotFound. + /// + /// In en, this message translates to: + /// **'Route not found: {route}'** + String errorRouteNotFound(String route); + /// No description provided for @actionYes. /// /// 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 73ff480e..32f6f603 100644 --- a/packages/neon/neon/lib/l10n/localizations_en.dart +++ b/packages/neon/neon/lib/l10n/localizations_en.dart @@ -123,6 +123,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get errorInvalidQrcode => 'Invalid QR-Code provided'; + @override + String errorRouteNotFound(String route) { + return 'Route not found: $route'; + } + @override String get actionYes => 'Yes'; diff --git a/packages/neon/neon/lib/src/pages/route_not_found.dart b/packages/neon/neon/lib/src/pages/route_not_found.dart new file mode 100644 index 00000000..884172f2 --- /dev/null +++ b/packages/neon/neon/lib/src/pages/route_not_found.dart @@ -0,0 +1,64 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:neon/blocs.dart'; +import 'package:neon/l10n/localizations.dart'; +import 'package:neon/src/router.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class RouteNotFoundPage extends StatefulWidget { + const RouteNotFoundPage({ + required this.uri, + super.key, + }); + + final Uri uri; + + @override + State createState() => _RouteNotFoundPageState(); +} + +class _RouteNotFoundPageState extends State { + @override + void initState() { + super.initState(); + + unawaited(_checkLaunchable()); + } + + Future _checkLaunchable() async { + final accountsBloc = Provider.of(context, listen: false); + if (!accountsBloc.hasAccounts) { + return; + } + + final activeAccount = accountsBloc.activeAccount.value!; + + final launched = await launchUrl( + activeAccount.completeUri(widget.uri), + mode: LaunchMode.externalApplication, + ); + if (!launched) { + return; + } + + if (context.mounted) { + const HomeRoute().go(context); + } + } + + @override + Widget build(final BuildContext context) => Scaffold( + appBar: AppBar( + leading: CloseButton( + onPressed: () { + const HomeRoute().go(context); + }, + ), + ), + body: Center( + child: Text(AppLocalizations.of(context).errorRouteNotFound(widget.uri.toString())), + ), + ); +} diff --git a/packages/neon/neon/lib/src/router.dart b/packages/neon/neon/lib/src/router.dart index 8db0007f..a0af9af9 100644 --- a/packages/neon/neon/lib/src/router.dart +++ b/packages/neon/neon/lib/src/router.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:meta/meta.dart'; import 'package:neon/src/blocs/accounts.dart'; @@ -17,6 +17,7 @@ import 'package:neon/src/pages/login_check_server_status.dart'; import 'package:neon/src/pages/login_flow.dart'; import 'package:neon/src/pages/login_qrcode.dart'; import 'package:neon/src/pages/nextcloud_app_settings.dart'; +import 'package:neon/src/pages/route_not_found.dart'; import 'package:neon/src/pages/settings.dart'; import 'package:neon/src/utils/stream_listenable.dart'; import 'package:provider/provider.dart'; @@ -33,6 +34,7 @@ class AppRouter extends GoRouter { refreshListenable: StreamListenable(accountsBloc.activeAccount), navigatorKey: navigatorKey, initialLocation: const HomeRoute().location, + errorPageBuilder: _buildErrorPage, redirect: (final context, final state) { final loginQrcode = LoginQrcode.tryParse(state.uri.toString()); if (loginQrcode != null) { @@ -43,6 +45,13 @@ class AppRouter extends GoRouter { ).location; } + if (accountsBloc.hasAccounts && state.uri.hasScheme) { + final strippedUri = accountsBloc.activeAccount.value!.stripUri(state.uri); + if (strippedUri != state.uri) { + return strippedUri.toString(); + } + } + // redirect to loginscreen when no account is logged in if (!accountsBloc.hasAccounts && !state.uri.toString().startsWith(const LoginRoute().location)) { return const LoginRoute().location; @@ -52,6 +61,12 @@ class AppRouter extends GoRouter { }, routes: $appRoutes, ); + + static Page _buildErrorPage(final BuildContext context, final GoRouterState state) => MaterialPage( + child: RouteNotFoundPage( + uri: state.uri, + ), + ); } @immutable From 9c6bdef1935641aed7549173418aec92ba3cecc7 Mon Sep 17 00:00:00 2001 From: jld3103 Date: Fri, 18 Aug 2023 18:46:25 +0200 Subject: [PATCH 5/5] feat(neon): Implement unified search Signed-off-by: jld3103 --- packages/neon/neon/lib/l10n/en.arb | 3 + .../neon/neon/lib/l10n/localizations.dart | 18 ++ .../neon/neon/lib/l10n/localizations_en.dart | 9 + .../neon/neon/lib/src/blocs/accounts.dart | 22 ++ .../neon/lib/src/blocs/unified_search.dart | 160 +++++++++++++++ packages/neon/neon/lib/src/pages/home.dart | 49 +++-- .../neon/neon/lib/src/widgets/app_bar.dart | 191 +++++++++++++----- .../neon/neon/lib/src/widgets/list_view.dart | 2 +- .../neon/lib/src/widgets/server_icon.dart | 4 + .../src/widgets/unified_search_results.dart | 143 +++++++++++++ 10 files changed, 525 insertions(+), 76 deletions(-) create mode 100644 packages/neon/neon/lib/src/blocs/unified_search.dart create mode 100644 packages/neon/neon/lib/src/widgets/unified_search_results.dart 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; + } +}