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