Kate
1 year ago
committed by
GitHub
15 changed files with 730 additions and 101 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,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())), |
||||||
|
), |
||||||
|
); |
||||||
|
} |
@ -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