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