Browse Source

Merge pull request #585 from nextcloud/performance/neonListView

Performance/neon list view
pull/877/head
Nikolas Rimikis 1 year ago committed by GitHub
parent
commit
9c87c12951
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 100
      packages/neon/neon/lib/src/widgets/list_view.dart
  2. 40
      packages/neon/neon/lib/src/widgets/unified_search_results.dart
  3. 2
      packages/neon/neon_files/lib/neon_files.dart
  4. 153
      packages/neon/neon_files/lib/widgets/browser_view.dart
  5. 13
      packages/neon/neon_files/lib/widgets/file_preview.dart
  6. 66
      packages/neon/neon_files/lib/widgets/navigator.dart
  7. 1
      packages/neon/neon_files/pubspec.yaml
  8. 18
      packages/neon/neon_news/lib/widgets/articles_view.dart
  9. 9
      packages/neon/neon_news/lib/widgets/feeds_view.dart
  10. 10
      packages/neon/neon_news/lib/widgets/folders_view.dart
  11. 9
      packages/neon/neon_notes/lib/widgets/categories_view.dart
  12. 7
      packages/neon/neon_notes/lib/widgets/notes_view.dart
  13. 7
      packages/neon/neon_notifications/lib/pages/main.dart

100
packages/neon/neon/lib/src/widgets/list_view.dart

@ -2,65 +2,85 @@ import 'package:flutter/material.dart';
import 'package:neon/src/widgets/error.dart'; import 'package:neon/src/widgets/error.dart';
import 'package:neon/src/widgets/linear_progress_indicator.dart'; import 'package:neon/src/widgets/linear_progress_indicator.dart';
class NeonListView<T> extends StatelessWidget { class NeonListView extends StatelessWidget {
const NeonListView({ NeonListView({
required this.items,
required this.isLoading, required this.isLoading,
required this.error, required this.error,
required this.onRefresh, required this.onRefresh,
required this.builder, required final NullableIndexedWidgetBuilder itemBuilder,
final int? itemCount,
this.scrollKey,
this.topFixedChildren,
this.topScrollingChildren,
super.key,
}) : sliver = SliverList.builder(
itemCount: itemCount,
itemBuilder: itemBuilder,
);
const NeonListView.custom({
required this.isLoading,
required this.error,
required this.onRefresh,
required this.sliver,
this.scrollKey, this.scrollKey,
this.withFloatingActionButton = false,
this.topFixedChildren, this.topFixedChildren,
this.topScrollingChildren, this.topScrollingChildren,
super.key, super.key,
}); });
final Iterable<T>? items;
final bool isLoading; final bool isLoading;
final Object? error; final Object? error;
final RefreshCallback onRefresh; final RefreshCallback onRefresh;
final Widget Function(BuildContext, T data) builder;
final String? scrollKey; final String? scrollKey;
final bool withFloatingActionButton;
final List<Widget>? topFixedChildren; final List<Widget>? topFixedChildren;
final List<Widget>? topScrollingChildren; final List<Widget>? topScrollingChildren;
final Widget sliver;
@override @override
Widget build(final BuildContext context) => RefreshIndicator( Widget build(final BuildContext context) {
onRefresh: onRefresh, final refreshIndicatorKey = GlobalKey<RefreshIndicatorState>();
child: Column( final hasFloatingActionButton = Scaffold.maybeOf(context)?.hasFloatingActionButton ?? false;
children: [
...?topFixedChildren, return RefreshIndicator.adaptive(
NeonLinearProgressIndicator( key: refreshIndicatorKey,
margin: const EdgeInsets.symmetric( onRefresh: onRefresh,
horizontal: 10, child: CustomScrollView(
vertical: 5, key: scrollKey != null ? PageStorageKey<String>(scrollKey!) : null,
), primary: true,
visible: isLoading, slivers: [
if (topFixedChildren != null)
SliverList.builder(
itemCount: topFixedChildren!.length,
itemBuilder: (final context, final index) => topFixedChildren![index],
), ),
Expanded( if (isLoading)
child: Scrollbar( const SliverToBoxAdapter(
child: ListView( child: NeonLinearProgressIndicator(
primary: true, margin: EdgeInsets.symmetric(
key: scrollKey != null ? PageStorageKey<String>(scrollKey!) : null, horizontal: 10,
padding: withFloatingActionButton ? const EdgeInsets.only(bottom: 88) : null, vertical: 5,
children: [
...?topScrollingChildren,
NeonError(
error,
onRetry: onRefresh,
),
if (items != null) ...[
for (final item in items!) ...[
builder(context, item),
],
],
],
), ),
), ),
), ),
], if (error != null)
), SliverToBoxAdapter(
); child: NeonError(
error,
onRetry: () async => refreshIndicatorKey.currentState!.show(),
),
),
if (topScrollingChildren != null)
SliverList.builder(
itemCount: topScrollingChildren!.length,
itemBuilder: (final context, final index) => topScrollingChildren![index],
),
SliverPadding(
padding: hasFloatingActionButton ? const EdgeInsets.only(bottom: 88) : EdgeInsets.zero,
sliver: sliver,
),
],
),
);
}
} }

40
packages/neon/neon/lib/src/widgets/unified_search_results.dart

@ -29,22 +29,30 @@ class NeonUnifiedSearchResults extends StatelessWidget {
final bloc = accountsBloc.activeUnifiedSearchBloc; final bloc = accountsBloc.activeUnifiedSearchBloc;
return ResultBuilder.behaviorSubject( return ResultBuilder.behaviorSubject(
stream: bloc.results, stream: bloc.results,
builder: (final context, final results) => NeonListView( builder: (final context, final results) {
items: results.data?.entries, final values = results.data?.entries.toList();
isLoading: results.isLoading,
error: results.error, return NeonListView(
onRefresh: bloc.refresh, isLoading: results.isLoading,
builder: (final context, final snapshot) => AnimatedSize( error: results.error,
duration: const Duration(milliseconds: 100), onRefresh: bloc.refresh,
child: _buildProvider( itemCount: values?.length ?? 0,
context, itemBuilder: (final context, final index) {
accountsBloc, final snapshot = values![index];
bloc,
snapshot.key, return AnimatedSize(
snapshot.value, duration: const Duration(milliseconds: 100),
), child: _buildProvider(
), context,
), accountsBloc,
bloc,
snapshot.key,
snapshot.value,
),
);
},
);
},
); );
} }

2
packages/neon/neon_files/lib/neon_files.dart

@ -11,7 +11,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:intersperse/intersperse.dart';
import 'package:neon/blocs.dart'; import 'package:neon/blocs.dart';
import 'package:neon/models.dart'; import 'package:neon/models.dart';
import 'package:neon/nextcloud.dart'; import 'package:neon/nextcloud.dart';
@ -44,6 +43,7 @@ part 'sort/files.dart';
part 'utils/task.dart'; part 'utils/task.dart';
part 'widgets/browser_view.dart'; part 'widgets/browser_view.dart';
part 'widgets/file_preview.dart'; part 'widgets/file_preview.dart';
part 'widgets/navigator.dart';
class FilesApp extends AppImplementation<FilesBloc, FilesAppSpecificOptions> { class FilesApp extends AppImplementation<FilesBloc, FilesAppSpecificOptions> {
FilesApp(); FilesApp();

153
packages/neon/neon_files/lib/widgets/browser_view.dart

@ -68,108 +68,65 @@ class _FilesBrowserViewState extends State<FilesBrowserView> {
input: files.data, input: files.data,
builder: (final context, final sorted) => ValueListenableBuilder( builder: (final context, final sorted) => ValueListenableBuilder(
valueListenable: widget.bloc.options.showHiddenFilesOption, valueListenable: widget.bloc.options.showHiddenFilesOption,
builder: (final context, final showHiddenFiles, final _) => NeonListView<Widget>( builder: (final context, final showHiddenFiles, final _) {
scrollKey: 'files-${pathSnapshot.requireData.join('/')}', final uploadingTasks = tasksSnapshot.requireData
withFloatingActionButton: true, .whereType<FilesUploadTask>()
items: [ .where(
for (final uploadTask in tasksSnapshot.requireData.whereType<FilesUploadTask>().where( (final task) =>
(final task) => sorted.where((final file) => _pathMatchesFile(task.path, file.name)).isEmpty,
sorted.where((final file) => _pathMatchesFile(task.path, file.name)).isEmpty, )
)) ...[ .toList();
FileListTile(
bloc: widget.filesBloc,
browserBloc: widget.bloc,
details: FileDetails.fromUploadTask(
task: uploadTask,
),
mode: widget.mode,
),
],
for (final file in sorted) ...[
if ((widget.mode != FilesBrowserMode.selectDirectory || file.isDirectory) &&
(!file.isHidden || showHiddenFiles)) ...[
Builder(
builder: (final context) {
final matchingTask = tasksSnapshot.requireData
.firstWhereOrNull((final task) => _pathMatchesFile(task.path, file.name));
final details = matchingTask != null return NeonListView(
? FileDetails.fromTask( scrollKey: 'files-${pathSnapshot.requireData.join('/')}',
task: matchingTask, itemCount: uploadingTasks.length + sorted.length,
file: file, itemBuilder: (final context, final index) {
) if (index < uploadingTasks.length) {
: FileDetails.fromWebDav( return FileListTile(
file: file, bloc: widget.filesBloc,
path: widget.bloc.path.value, browserBloc: widget.bloc,
); details: FileDetails.fromUploadTask(
task: uploadingTasks[index],
),
);
}
return FileListTile( final file = sorted[index - uploadingTasks.length];
bloc: widget.filesBloc, if ((widget.mode != FilesBrowserMode.selectDirectory || file.isDirectory) &&
browserBloc: widget.bloc, (!file.isHidden || showHiddenFiles)) {
details: details, final matchingTask = tasksSnapshot.requireData
mode: widget.mode, .firstWhereOrNull((final task) => _pathMatchesFile(task.path, file.name));
);
}, final details = matchingTask != null
), ? FileDetails.fromTask(
], task: matchingTask,
], file: file,
],
isLoading: files.isLoading,
error: files.error,
onRefresh: widget.bloc.refresh,
builder: (final context, final widget) => widget,
topScrollingChildren: [
Align(
alignment: Alignment.topLeft,
child: Container(
margin: const EdgeInsets.symmetric(
horizontal: 10,
),
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
IconButton(
padding: EdgeInsets.zero,
tooltip: AppLocalizations.of(context).goToPath(''),
icon: const Icon(
Icons.house,
),
onPressed: () {
widget.bloc.setPath([]);
},
),
for (var i = 0; i < pathSnapshot.requireData.length; i++) ...[
Builder(
builder: (final context) {
final path = pathSnapshot.requireData.sublist(0, i + 1);
return Tooltip(
message: AppLocalizations.of(context).goToPath(path.join('/')),
excludeFromSemantics: true,
child: TextButton(
onPressed: () {
widget.bloc.setPath(path);
},
child: Text(
pathSnapshot.requireData[i],
semanticsLabel: AppLocalizations.of(context).goToPath(path.join('/')),
),
),
);
},
),
],
]
.intersperse(
const Icon(
Icons.keyboard_arrow_right,
),
) )
.toList(), : FileDetails.fromWebDav(
), file: file,
path: widget.bloc.path.value,
);
return FileListTile(
bloc: widget.filesBloc,
browserBloc: widget.bloc,
details: details,
);
}
return null;
},
isLoading: files.isLoading,
error: files.error,
onRefresh: widget.bloc.refresh,
topScrollingChildren: [
FilesBrowserNavigator(
path: pathSnapshot.requireData,
bloc: widget.bloc,
), ),
), ],
], );
), },
), ),
), ),
), ),

13
packages/neon/neon_files/lib/widgets/file_preview.dart

@ -39,7 +39,7 @@ class FilePreview extends StatelessWidget {
return ValueListenableBuilder<bool>( return ValueListenableBuilder<bool>(
valueListenable: bloc.options.showPreviewsOption, valueListenable: bloc.options.showPreviewsOption,
builder: (final context, final showPreviews, final child) { builder: (final context, final showPreviews, final _) {
if (showPreviews && (details.hasPreview ?? false)) { if (showPreviews && (details.hasPreview ?? false)) {
final preview = FilePreviewImage( final preview = FilePreviewImage(
file: details, file: details,
@ -56,13 +56,12 @@ class FilePreview extends StatelessWidget {
return preview; return preview;
} }
return child!; return FileIcon(
details.name,
color: color,
size: size.shortestSide,
);
}, },
child: FileIcon(
details.name,
color: color,
size: size.shortestSide,
),
); );
}, },
), ),

66
packages/neon/neon_files/lib/widgets/navigator.dart

@ -0,0 +1,66 @@
part of '../neon_files.dart';
class FilesBrowserNavigator extends StatelessWidget {
const FilesBrowserNavigator({
required this.path,
required this.bloc,
super.key,
});
final List<String> path;
final FilesBrowserBloc bloc;
static const double _height = 30;
@override
Widget build(final BuildContext context) => SizedBox(
height: _height,
child: ListView.separated(
padding: const EdgeInsets.symmetric(
horizontal: 10,
),
scrollDirection: Axis.horizontal,
itemCount: path.length + 1,
itemBuilder: (final context, final index) {
if (index == 0) {
return IconButton(
padding: EdgeInsets.zero,
visualDensity: const VisualDensity(
horizontal: VisualDensity.minimumDensity,
vertical: VisualDensity.minimumDensity,
),
tooltip: AppLocalizations.of(context).goToPath(''),
icon: const Icon(
Icons.house,
size: _height,
),
onPressed: () {
bloc.setPath([]);
},
);
}
final path = this.path.sublist(0, index);
final label = path.join('/');
return Tooltip(
message: AppLocalizations.of(context).goToPath(label),
excludeFromSemantics: true,
child: TextButton(
onPressed: () {
bloc.setPath(path);
},
child: Text(
path.last,
semanticsLabel: AppLocalizations.of(context).goToPath(label),
),
),
);
},
separatorBuilder: (final context, final index) => const Icon(
Icons.keyboard_arrow_right,
size: _height,
),
),
);
}

1
packages/neon/neon_files/pubspec.yaml

@ -21,7 +21,6 @@ dependencies:
flutter_material_design_icons: ^1.1.7296 flutter_material_design_icons: ^1.1.7296
go_router: ^11.1.2 go_router: ^11.1.2
image_picker: ^1.0.4 image_picker: ^1.0.4
intersperse: ^2.0.0
intl: ^0.18.1 intl: ^0.18.1
neon: neon:
git: git:

18
packages/neon/neon_news/lib/widgets/articles_view.dart

@ -34,9 +34,8 @@ class _NewsArticlesViewState extends State<NewsArticlesView> {
sortPropertyOption: widget.newsBloc.options.articlesSortPropertyOption, sortPropertyOption: widget.newsBloc.options.articlesSortPropertyOption,
sortBoxOrderOption: widget.newsBloc.options.articlesSortBoxOrderOption, sortBoxOrderOption: widget.newsBloc.options.articlesSortBoxOrderOption,
input: articles.data, input: articles.data,
builder: (final context, final sorted) => NeonListView<NewsArticle>( builder: (final context, final sorted) => NeonListView(
scrollKey: 'news-articles', scrollKey: 'news-articles',
items: feeds.hasData ? sorted : null,
isLoading: articles.isLoading || feeds.isLoading, isLoading: articles.isLoading || feeds.isLoading,
error: articles.error ?? feeds.error, error: articles.error ?? feeds.error,
onRefresh: () async { onRefresh: () async {
@ -45,11 +44,16 @@ class _NewsArticlesViewState extends State<NewsArticlesView> {
widget.newsBloc.refresh(), widget.newsBloc.refresh(),
]); ]);
}, },
builder: (final context, final article) => _buildArticle( itemCount: feeds.hasData ? sorted.length : null,
context, itemBuilder: (final context, final index) {
article, final article = sorted[index];
feeds.requireData.singleWhere((final feed) => feed.id == article.feedId),
), return _buildArticle(
context,
article,
feeds.requireData.singleWhere((final feed) => feed.id == article.feedId),
);
},
topFixedChildren: [ topFixedChildren: [
StreamBuilder<FilterType>( StreamBuilder<FilterType>(
stream: widget.bloc.filterType, stream: widget.bloc.filterType,

9
packages/neon/neon_news/lib/widgets/feeds_view.dart

@ -22,16 +22,15 @@ class NewsFeedsView extends StatelessWidget {
input: folders.hasData input: folders.hasData
? feeds.data?.where((final f) => folderID == null || f.folderId == folderID).toList() ? feeds.data?.where((final f) => folderID == null || f.folderId == folderID).toList()
: null, : null,
builder: (final context, final sorted) => NeonListView<NewsFeed>( builder: (final context, final sorted) => NeonListView(
scrollKey: 'news-feeds', scrollKey: 'news-feeds',
withFloatingActionButton: true,
items: sorted,
isLoading: feeds.isLoading || folders.isLoading, isLoading: feeds.isLoading || folders.isLoading,
error: feeds.error ?? folders.error, error: feeds.error ?? folders.error,
onRefresh: bloc.refresh, onRefresh: bloc.refresh,
builder: (final context, final feed) => _buildFeed( itemCount: sorted.length,
itemBuilder: (final context, final index) => _buildFeed(
context, context,
feed, sorted[index],
folders.requireData, folders.requireData,
), ),
), ),

10
packages/neon/neon_news/lib/widgets/folders_view.dart

@ -26,14 +26,16 @@ class NewsFoldersView extends StatelessWidget {
return (folder, feedCount, unreadCount); return (folder, feedCount, unreadCount);
}).toList() }).toList()
: null, : null,
builder: (final context, final sorted) => NeonListView<FolderFeedsWrapper>( builder: (final context, final sorted) => NeonListView(
scrollKey: 'news-folders', scrollKey: 'news-folders',
withFloatingActionButton: true,
items: sorted,
isLoading: feeds.isLoading || folders.isLoading, isLoading: feeds.isLoading || folders.isLoading,
error: feeds.error ?? folders.error, error: feeds.error ?? folders.error,
onRefresh: bloc.refresh, onRefresh: bloc.refresh,
builder: _buildFolder, itemCount: sorted.length,
itemBuilder: (final context, final index) => _buildFolder(
context,
sorted[index],
),
), ),
), ),
), ),

9
packages/neon/neon_notes/lib/widgets/categories_view.dart

@ -25,13 +25,16 @@ class NotesCategoriesView extends StatelessWidget {
), ),
) )
.toList(), .toList(),
builder: (final context, final sorted) => NeonListView<NoteCategory>( builder: (final context, final sorted) => NeonListView(
scrollKey: 'notes-categories', scrollKey: 'notes-categories',
items: sorted,
isLoading: notes.isLoading, isLoading: notes.isLoading,
error: notes.error, error: notes.error,
onRefresh: bloc.refresh, onRefresh: bloc.refresh,
builder: _buildCategory, itemCount: sorted.length,
itemBuilder: (final context, final index) => _buildCategory(
context,
sorted[index],
),
), ),
), ),
); );

7
packages/neon/neon_notes/lib/widgets/notes_view.dart

@ -21,14 +21,13 @@ class NotesView extends StatelessWidget {
sortPropertyOption: bloc.options.notesSortPropertyOption, sortPropertyOption: bloc.options.notesSortPropertyOption,
sortBoxOrderOption: bloc.options.notesSortBoxOrderOption, sortBoxOrderOption: bloc.options.notesSortBoxOrderOption,
input: category != null ? notes.data?.where((final note) => note.category == category).toList() : notes.data, input: category != null ? notes.data?.where((final note) => note.category == category).toList() : notes.data,
builder: (final context, final sorted) => NeonListView<NotesNote>( builder: (final context, final sorted) => NeonListView(
scrollKey: 'notes-notes', scrollKey: 'notes-notes',
withFloatingActionButton: true,
items: sorted,
isLoading: notes.isLoading, isLoading: notes.isLoading,
error: notes.error, error: notes.error,
onRefresh: bloc.refresh, onRefresh: bloc.refresh,
builder: _buildNote, itemCount: sorted.length,
itemBuilder: (final context, final index) => _buildNote(context, sorted[index]),
), ),
), ),
); );

7
packages/neon/neon_notifications/lib/pages/main.dart

@ -35,14 +35,13 @@ class _NotificationsMainPageState extends State<NotificationsMainPage> {
tooltip: AppLocalizations.of(context).notificationsDismissAll, tooltip: AppLocalizations.of(context).notificationsDismissAll,
child: const Icon(MdiIcons.checkAll), child: const Icon(MdiIcons.checkAll),
), ),
body: NeonListView<NotificationsNotification>( body: NeonListView(
scrollKey: 'notifications-notifications', scrollKey: 'notifications-notifications',
withFloatingActionButton: true,
items: notifications.data,
isLoading: notifications.isLoading, isLoading: notifications.isLoading,
error: notifications.error, error: notifications.error,
onRefresh: bloc.refresh, onRefresh: bloc.refresh,
builder: _buildNotification, itemCount: notifications.data?.length,
itemBuilder: (final context, final index) => _buildNotification(context, notifications.data![index]),
), ),
), ),
); );

Loading…
Cancel
Save