10 changed files with 525 additions and 76 deletions
			
			
		@ -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); | 
				
			||||||
 | 
					} | 
				
			||||||
@ -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…
					
					
				
		Reference in new issue