Browse Source

Merge pull request #399 from nextcloud/feature/unified-search

Implement unified search
pull/610/head
Kate 1 year ago committed by GitHub
parent
commit
105f2375a2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 11
      packages/neon/neon/lib/l10n/en.arb
  2. 24
      packages/neon/neon/lib/l10n/localizations.dart
  3. 14
      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. 22
      packages/neon/neon/lib/src/models/account.dart
  7. 49
      packages/neon/neon/lib/src/pages/home.dart
  8. 64
      packages/neon/neon/lib/src/pages/route_not_found.dart
  9. 17
      packages/neon/neon/lib/src/router.dart
  10. 191
      packages/neon/neon/lib/src/widgets/app_bar.dart
  11. 52
      packages/neon/neon/lib/src/widgets/cached_image.dart
  12. 2
      packages/neon/neon/lib/src/widgets/list_view.dart
  13. 4
      packages/neon/neon/lib/src/widgets/server_icon.dart
  14. 143
      packages/neon/neon/lib/src/widgets/unified_search_results.dart
  15. 56
      packages/neon/neon/test/account_test.dart

11
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",
@ -81,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",

24
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:
@ -335,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:

14
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';
@ -157,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);
}

22
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<String, String> _idCache = {};

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/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<HomePage> {
const drawer = NeonDrawer();
const appBar = NeonAppBar();
final appView = ResultBuilder<Iterable<AppImplementation>>.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<AppImplementation>(
stream: _appsBloc.activeApp,
builder: (final context, final activeAppIDSnapshot) {
if (!activeAppIDSnapshot.hasData) {
return ResultBuilder<Iterable<AppImplementation>>.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<AppImplementation>(
stream: _appsBloc.activeApp,
builder: (final context, final activeAppIDSnapshot) {
if (!activeAppIDSnapshot.hasData) {
return const SizedBox();
}
return activeAppIDSnapshot.requireData.page;
},
);
},
);
},

64
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<RouteNotFoundPage> createState() => _RouteNotFoundPageState();
}
class _RouteNotFoundPageState extends State<RouteNotFoundPage> {
@override
void initState() {
super.initState();
unawaited(_checkLaunchable());
}
Future _checkLaunchable() async {
final accountsBloc = Provider.of<AccountsBloc>(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())),
),
);
}

17
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

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/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;
return 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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (activeAppSnapshot.hasData) ...[
Flexible(
child: Text(
activeAppSnapshot.requireData.name(context),
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();
}
});
_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,
),
],
],
),
),
],
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<AccountsBloc>(context, listen: false).activeUnifiedSearchBloc.enable();
},
tooltip: AppLocalizations.of(context).search,
icon: const Icon(
Icons.search,
size: kAvatarSize * 2 / 3,
),
);
}
@internal

52
packages/neon/neon/lib/src/widgets/cached_image.dart

@ -5,12 +5,14 @@ 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';
typedef CacheReviver = FutureOr<Uint8List?> Function(CacheManager cacheManager);
typedef ImageDownloader = FutureOr<Uint8List> Function();
typedef CacheWriter = Future<void> Function(CacheManager cacheManager, Uint8List image);
typedef ErrorWidgetBuilder = Widget? Function(BuildContext, dynamic);
class NeonCachedImage extends StatefulWidget {
const NeonCachedImage({
@ -21,17 +23,20 @@ class NeonCachedImage extends StatefulWidget {
this.fit,
this.svgColor,
this.iconColor,
this.errorBuilder,
});
NeonCachedImage.url({
required final String url,
final Account? account,
final Key? key,
this.isSvgHint = false,
this.size,
this.fit,
this.svgColor,
this.iconColor,
}) : image = _getImageFromUrl(url),
this.errorBuilder,
}) : image = _getImageFromUrl(url, account),
super(key: key ?? Key(url));
NeonCachedImage.custom({
@ -44,6 +49,7 @@ class NeonCachedImage extends StatefulWidget {
this.fit,
this.svgColor,
this.iconColor,
this.errorBuilder,
}) : image = _customImageGetter(
reviver,
getImage,
@ -61,8 +67,20 @@ class NeonCachedImage extends StatefulWidget {
final Color? svgColor;
final Color? iconColor;
static Future<Uint8List> _getImageFromUrl(final String url) async {
final file = await _cacheManager.getSingleFile(url);
final ErrorWidgetBuilder? errorBuilder;
static Future<Uint8List> _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();
}
@ -116,6 +134,10 @@ class _NeonCachedImageState extends State<NeonCachedImage> {
child: FutureBuilder<Uint8List>(
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 +147,6 @@ class _NeonCachedImageState extends State<NeonCachedImage> {
);
}
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 +170,20 @@ class _NeonCachedImageState extends State<NeonCachedImage> {
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,
);
}

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;
}
}

56
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'),
);
});
});
}
});
}

Loading…
Cancel
Save